diff --git a/.github/workflows/docker_build_stg_images.yml b/.github/workflows/docker_build_stg_images.yml index 781b76f6d..887d7c1e1 100644 --- a/.github/workflows/docker_build_stg_images.yml +++ b/.github/workflows/docker_build_stg_images.yml @@ -34,13 +34,20 @@ jobs: with: images: os2display/display-api-service + - name: Set release timestamp + run: | + echo "APP_RELEASE_TIMESTAMP=$(echo $(date +%s))" >> $GITHUB_ENV + echo "APP_RELEASE_TIME=$(echo $(date))" >> $GITHUB_ENV + - name: Build and push (API) uses: docker/build-push-action@v6 with: context: ./infrastructure/display-api-service/ file: ./infrastructure/display-api-service/Dockerfile build-args: | - VERSION=${{ env.APP_VERSION }} + APP_VERSION=${{ env.APP_VERSION }} + APP_RELEASE_TIMESTAMP=${{ env.APP_RELEASE_TIMESTAMP }} + APP_RELEASE_TIME=${{ env.APP_RELEASE_TIME }} push: true tags: ${{ steps.meta-api.outputs.tags }} labels: ${{ steps.meta-api.outputs.labels }} diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml index ea6bbe7c5..975b32f49 100644 --- a/.github/workflows/github_build_release.yml +++ b/.github/workflows/github_build_release.yml @@ -35,6 +35,16 @@ jobs: docker compose run --rm node npm install docker compose run --rm node npm run build + - name: Set release timestamp + run: | + echo "APP_RELEASE_TIMESTAMP=$(echo $(date +%s))" >> $GITHUB_ENV + echo "APP_RELEASE_TIME=$(echo $(date))" >> $GITHUB_ENV + + - name: Create release file + run: | + printf "{\n \"releaseTimestamp\": ${{ env.APP_RELEASE_TIMESTAMP }},\n \"releaseTime\": \"${{ env.APP_RELEASE_TIME }}\",\n \"releaseVersion\": \"${{ github.ref_name }}\"\n}" > public/release.json + cat public/release.json + - name: Cleanup after install run: | sudo chown -R runner:runner . @@ -102,6 +112,11 @@ jobs: tags: | type=raw,value=${{ github.ref_name }} + - name: Set release timestamp + run: | + echo "APP_RELEASE_TIMESTAMP=$(echo $(date +%s))" >> $GITHUB_ENV + echo "APP_RELEASE_TIME=$(echo $(date))" >> $GITHUB_ENV + - name: Build and push Docker image (main) id: push-main uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 @@ -110,6 +125,8 @@ jobs: file: ./infrastructure/display-api-service/Dockerfile build-args: | APP_VERSION=${{ github.ref_name }} + APP_RELEASE_TIMESTAMP=${{ env.APP_RELEASE_TIMESTAMP }} + APP_RELEASE_TIME=${{ env.APP_RELEASE_TIME }} push: true tags: ${{ steps.meta-main.outputs.tags }} labels: ${{ steps.meta-main.outputs.labels }} @@ -132,6 +149,8 @@ jobs: file: ./infrastructure/nginx/Dockerfile build-args: | APP_VERSION=${{ github.ref_name }} + APP_RELEASE_TIMESTAMP=${{ env.APP_RELEASE_TIMESTAMP }} + APP_RELEASE_TIME=${{ env.APP_RELEASE_TIME }} APP_IMAGE=${{ env.IMAGE_NAME_MAIN }} push: true pull: true diff --git a/.gitignore b/.gitignore index 521b8cb58..40168e85a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,5 @@ phpstan.neon ###> vincentlanglet/twig-cs-fixer ### /.twig-cs-fixer.cache ###< vincentlanglet/twig-cs-fixer ### + +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f5ee5e0..b86e7b614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ All notable changes to this project will be documented in this file. - Added relations checksum feature flag. - Fixes saving issues described in issue where saving resulted in infinite spinner. - Fixed loading of routes containing null string values. +- Fixed release.json creation in v3. +- Fixed relations checksum test. +- Optimized release data fetching. +- Optimized list loading. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/README.md b/README.md index 7f5c8da73..425b87728 100644 --- a/README.md +++ b/README.md @@ -509,9 +509,11 @@ CLIENT_DEBUG=false waiting for being activated in the administration. **Default**: 20 s. -- CLIENT_REFRESH_TOKEN_TIMEOUT: How often (milliseconds) should it be checked whether the token needs to be refreshed? +- CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT: How often (milliseconds) should it be checked whether a new release is + available? + Value should not be lower than 5 minutes, since release.json is only fetched with a minimum of 5 minutes interval. - **Default**: 30 s. + **Default**: 10 m. - CLIENT_REFRESH_TOKEN_TIMEOUT: How often (milliseconds) should it be checked whether the token needs to be refreshed? **Default**: 60 s. diff --git a/Taskfile.yml b/Taskfile.yml index 088ee25f8..9ec773a3c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -192,7 +192,7 @@ tasks: desc: "Runs API tests (PHPUnit)." cmds: - task composer -- test-setup - - task compose -- exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm composer test + - task compose -- exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm vendor/bin/phpunit --stop-on-failure {{.CLI_ARGS}} test:frontend-built: desc: "Runs frontend tests (Playwright) on the built files. This temporarily stops the node container." diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx index 3d06dac1f..191df9414 100644 --- a/assets/admin/components/groups/groups-columns.jsx +++ b/assets/admin/components/groups/groups-columns.jsx @@ -1,7 +1,57 @@ import { useTranslation } from "react-i18next"; -import ListButton from "../util/list/list-button"; import ColumnHoc from "../util/column-hoc"; import SelectColumnHoc from "../util/select-column-hoc"; +import useModal from "../../context/modal-context/modal-context-hook.jsx"; +import { useDispatch } from "react-redux"; +import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import idFromUrl from "../util/helpers/id-from-url.jsx"; +import getAllPages from "../util/helpers/get-all-pages.js"; +import { Link } from "react-router-dom"; +import { Button } from "react-bootstrap"; + +function ScreensButton({ group }) { + const { t } = useTranslation("common", { keyPrefix: "groups-columns" }); + const { setModal } = useModal(); + const dispatch = useDispatch(); + + const onClick = () => { + getAllPages(dispatch, enhancedApi.endpoints.getV2ScreenGroupsByIdScreens, { + id: idFromUrl(group.id), + }).then((screens) => { + const content = ( + + ); + + setModal({ + info: true, + modalTitle: t("screens-modal-title"), + content, + }); + }); + }; + + return ( + + ); +} /** * Columns for group lists. @@ -18,14 +68,7 @@ function getGroupColumns({ apiCall, infoModalRedirect, infoModalTitle }) { const columns = [ { // eslint-disable-next-line react/prop-types - content: ({ screens }) => ( - - ), + content: (group) => , key: "screens", label: t("screens"), }, diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx index 5657bcc1f..d7c93be26 100644 --- a/assets/admin/components/playlist/playlists-columns.jsx +++ b/assets/admin/components/playlist/playlists-columns.jsx @@ -1,11 +1,59 @@ -import { useContext } from "react"; import { useTranslation } from "react-i18next"; import ColumnHoc from "../util/column-hoc"; import SelectColumnHoc from "../util/select-column-hoc"; -import UserContext from "../../context/user-context"; -import ListButton from "../util/list/list-button"; import DateValue from "../util/date-value"; import PublishingStatus from "../util/publishingStatus"; +import useModal from "../../context/modal-context/modal-context-hook.jsx"; +import { useDispatch } from "react-redux"; +import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import idFromUrl from "../util/helpers/id-from-url.jsx"; +import getAllPages from "../util/helpers/get-all-pages.js"; +import { Link } from "react-router-dom"; +import { Button } from "react-bootstrap"; + +function SlidesButton({ playlist }) { + const { t } = useTranslation("common", { keyPrefix: "playlists-columns" }); + const { setModal } = useModal(); + const dispatch = useDispatch(); + + const onClick = () => { + getAllPages(dispatch, enhancedApi.endpoints.getV2PlaylistsByIdSlides, { + id: idFromUrl(playlist.id), + }).then((playlistSlides) => { + const content = ( + + ); + + setModal({ + info: true, + modalTitle: t("playlist-slide-modal-title"), + content, + }); + }); + }; + + return ( + + ); +} /** * Columns for playlists lists. @@ -17,40 +65,16 @@ import PublishingStatus from "../util/publishingStatus"; * @param {string} props.dataKey The data key for mapping the data. * @returns {object} The columns for the playlists lists. */ -function getPlaylistColumns({ - apiCall, - infoModalRedirect, - infoModalTitle, - dataKey, -}) { - const context = useContext(UserContext); +function getPlaylistColumns() { const { t } = useTranslation("common", { keyPrefix: "playlists-columns", }); const columns = [ { - key: "slides", + key: "playlist", label: t("number-of-slides"), - render: ({ tenants }) => { - return ( - tenants?.length === 0 || - !tenants.find( - (tenant) => - tenant.tenantKey === context.selectedTenant.get.tenantKey, - ) - ); - }, - // eslint-disable-next-line react/prop-types - content: ({ slides, playlistSlides }) => ( - - ), + content: (playlist) => , }, { key: "publishing-from", diff --git a/assets/admin/components/screen/screen-list.jsx b/assets/admin/components/screen/screen-list.jsx index 48e82ae9d..9c5c33dd2 100644 --- a/assets/admin/components/screen/screen-list.jsx +++ b/assets/admin/components/screen/screen-list.jsx @@ -11,7 +11,6 @@ import useModal from "../../context/modal-context/modal-context-hook"; import { useGetV2ScreensQuery, useDeleteV2ScreensByIdMutation, - useGetV2ScreensByIdScreenGroupsQuery, } from "../../../shared/redux/enhanced-api.ts"; import { displaySuccess, @@ -128,10 +127,6 @@ function ScreenList() { // The columns for the table. const columns = ScreenColumns({ - handleDelete, - apiCall: useGetV2ScreensByIdScreenGroupsQuery, - infoModalRedirect: "/group/edit", - infoModalTitle: t("info-modal.screen-in-groups"), displayStatus: showScreenStatus, }); diff --git a/assets/admin/components/screen/screen-status.jsx b/assets/admin/components/screen/screen-status.jsx index 972c39cf2..b6ee26645 100644 --- a/assets/admin/components/screen/screen-status.jsx +++ b/assets/admin/components/screen/screen-status.jsx @@ -18,6 +18,7 @@ import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; import { displayError } from "../util/list/toast-component/display-toast"; import FormInput from "../util/forms/form-input"; import AdminConfigLoader from "../util/admin-config-loader.js"; +import ReleaseLoader from "../../../shared/release-loader.js"; /** * Displays screen status. @@ -92,11 +93,7 @@ function ScreenStatus({ screen, handleInput = () => {}, mode = "default" }) { useEffect(() => { if (status) { - const now = dayjs().startOf("minute").valueOf(); - - fetch(`/release.json?ts=${now}`) - .then((res) => res.json()) - .then((data) => setClientRelease(data)); + ReleaseLoader.loadRelease().then((data) => setClientRelease(data)); } }, [status]); diff --git a/assets/admin/components/screen/util/campaign-icon.jsx b/assets/admin/components/screen/util/campaign-icon.jsx deleted file mode 100644 index b61f13598..000000000 --- a/assets/admin/components/screen/util/campaign-icon.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useEffect, useState } from "react"; -import { useDispatch } from "react-redux"; -import { useTranslation } from "react-i18next"; -import Spinner from "react-bootstrap/Spinner"; -import idFromUrl from "../../util/helpers/id-from-url"; -import calculateIsPublished from "../../util/helpers/calculate-is-published"; -import { - enhancedApi, - useGetV2ScreensByIdCampaignsQuery, - useGetV2ScreensByIdScreenGroupsQuery, -} from "../../../../shared/redux/enhanced-api.ts"; - -/** - * An icon to show if the screen has an active campaign. - * - * @param {object} props - The props. - * @param {string} props.id The id of the screen. - * @param {number} props.delay Delay the fetch. - * @returns {object} The campaign icon. - */ -function CampaignIcon({ id, delay = 1000 }) { - const { t } = useTranslation("common", { keyPrefix: "campaign-icon" }); - const dispatch = useDispatch(); - const [isOverriddenByCampaign, setIsOverriddenByCampaign] = useState(null); - const [screenCampaignsChecked, setScreenCampaignsChecked] = useState(false); - const [allCampaigns, setAllCampaigns] = useState([]); - const [getData, setGetData] = useState(false); - - const { data: campaigns, isLoading } = useGetV2ScreensByIdCampaignsQuery( - { id }, - { skip: !getData || !id }, - ); - const { data: groups, isLoading: isLoadingScreenGroups } = - useGetV2ScreensByIdScreenGroupsQuery({ id }, { skip: !getData || !id }); - - useEffect(() => { - if (campaigns) { - setAllCampaigns( - campaigns["hydra:member"].map(({ campaign }) => campaign), - ); - setScreenCampaignsChecked(true); - } - }, [campaigns]); - - useEffect(() => { - if (groups && !isOverriddenByCampaign && screenCampaignsChecked) { - groups["hydra:member"].forEach((group) => { - dispatch( - enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ - id: idFromUrl(group["@id"]), - }), - ).then((result) => { - let allCampaignsCopy = [...allCampaigns]; - if (allCampaignsCopy.length > 0 && result.data) { - allCampaignsCopy = allCampaignsCopy.concat( - result.data["hydra:member"].map(({ campaign }) => campaign), - ); - } - setAllCampaigns(allCampaignsCopy); - }); - }); - } - }, [groups, screenCampaignsChecked]); - - useEffect(() => { - if (allCampaigns.length > 0 && !isOverriddenByCampaign) { - allCampaigns.forEach(({ published }) => { - if (calculateIsPublished(published)) { - setIsOverriddenByCampaign(true); - } - }); - } - }, [allCampaigns]); - - useEffect(() => { - const timeout = setTimeout(() => { - setGetData(true); - }, delay); - - return () => { - clearTimeout(timeout); - }; - }, []); - - if (!getData || isLoading || isLoadingScreenGroups) { - return ( -
-
- ); - } - - return isOverriddenByCampaign - ? t("overridden-by-campaign") - : t("not-overridden-by-campaign"); -} - -export default CampaignIcon; diff --git a/assets/admin/components/screen/util/campaigns-button.jsx b/assets/admin/components/screen/util/campaigns-button.jsx new file mode 100644 index 000000000..edb5657c3 --- /dev/null +++ b/assets/admin/components/screen/util/campaigns-button.jsx @@ -0,0 +1,165 @@ +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import calculateIsPublished from "../../util/helpers/calculate-is-published.jsx"; +import idFromUrl from "../../util/helpers/id-from-url.jsx"; +import getAllPages from "../../util/helpers/get-all-pages.js"; +import { useDispatch } from "react-redux"; +import useModal from "../../../context/modal-context/modal-context-hook.jsx"; +import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; +import { Button } from "react-bootstrap"; + +function getAllScreenGroupCampaigns(dispatch, screenGroupIds = []) { + return screenGroupIds.reduce( + (promise, groupId) => + promise.then((results) => + getAllPages( + dispatch, + enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns, + { id: groupId }, + ).then((campaigns) => [...results, ...campaigns]), + ), + Promise.resolve([]), + ); +} + +function getAllCampaigns(dispatch, campaignIds = [], results = []) { + return new Promise((resolve) => { + if (campaignIds.length === 0) { + resolve(results); + } else { + const campaignId = campaignIds[0]; + + dispatch( + enhancedApi.endpoints.getV2PlaylistsById.initiate({ + id: campaignId, + }), + ).then(({ data }) => { + const newResults = [...results, data]; + + const newCampaignIds = campaignIds.filter((id) => id !== campaignId); + + if (newCampaignIds.length === 0) { + resolve(newResults); + } else { + resolve(getAllCampaigns(dispatch, newCampaignIds, newResults)); + } + }); + } + }); +} + +function CampaignsButton({ screen }) { + const { t } = useTranslation("common", { keyPrefix: "screen-columns" }); + const { setModal } = useModal(); + const dispatch = useDispatch(); + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(false); + + const onClick = () => { + setLoading(true); + // Fetch screen groups. + // Fetch screen group campaigns. + // Fetch screen campaigns. + // Merge campaign arrays. + // Set campaigns to trigger useEffect. + getAllPages(dispatch, enhancedApi.endpoints.getV2ScreensByIdScreenGroups, { + id: screen.id, + }) + .then((screenGroups) => { + const screenGroupIds = screenGroups + .filter(({ campaignsLength }) => campaignsLength > 0) + .map((group) => idFromUrl(group["@id"])); + + return getAllScreenGroupCampaigns(dispatch, screenGroupIds).then( + (screenGroupCampaigns) => { + return getAllPages( + dispatch, + enhancedApi.endpoints.getV2ScreensByIdCampaigns, + { id: screen.id }, + ).then((screenCampaigns) => { + const campaignRelations = [ + ...screenGroupCampaigns, + ...screenCampaigns, + ]; + const campaigns = campaignRelations.map( + (campaignRelation) => campaignRelation.campaign, + ); + const ids = new Set(); + const uniqueCampaigns = campaigns.filter( + (campaign) => + !ids.has(campaign["@id"]) && ids.add(campaign["@id"]), + ); + + return getAllCampaigns( + dispatch, + uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"])), + ).then((allCampaigns) => { + setCampaigns(allCampaigns); + setLoading(false); + }); + }); + }, + ); + }) + .catch(() => setLoading(false)); + }; + + useEffect(() => { + if (campaigns?.length > 0) { + const content = ( +
    + {campaigns.map((campaign) => ( +
  • + + {campaign.title} + + {calculateIsPublished(campaign.published) && ( + Aktiv + )} +
  • + ))} +
+ ); + + setModal({ + info: true, + modalTitle: t("campaigns-modal-title"), + content, + }); + } + }, [campaigns]); + + return ( + + ); +} + +export default CampaignsButton; diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx index b556b76c8..3f359cc9f 100644 --- a/assets/admin/components/screen/util/screen-columns.jsx +++ b/assets/admin/components/screen/util/screen-columns.jsx @@ -1,43 +1,23 @@ import { useTranslation } from "react-i18next"; -import ListButton from "../../util/list/list-button"; -import CampaignIcon from "./campaign-icon"; import SelectColumnHoc from "../../util/select-column-hoc"; import ColumnHoc from "../../util/column-hoc"; -import idFromUrl from "../../util/helpers/id-from-url"; import ScreenStatus from "../screen-status"; +import CampaignsButton from "./campaigns-button.jsx"; +import ScreenGroupsButton from "./screen-groups-button.jsx"; /** * Columns for screens lists. * * @param {object} props - The props. - * @param {Function} props.apiCall - The api to call - * @param {string} props.infoModalRedirect - The url for redirecting in the info modal. - * @param {string} props.infoModalTitle - The info modal title. - * @param {string} props.dataKey The data key for mapping the data. * @param {boolean} props.displayStatus Should status be displayed? * @returns {object} The columns for the screens lists. */ -function getScreenColumns({ - apiCall, - infoModalRedirect, - infoModalTitle, - dataKey, - displayStatus, -}) { +function getScreenColumns({ displayStatus }) { const { t } = useTranslation("common", { keyPrefix: "screen-list" }); const columns = [ { - content: (screen) => ( - - ), + content: (screen) => , key: "groups", label: t("columns.on-groups"), }, @@ -48,8 +28,7 @@ function getScreenColumns({ { key: "campaign", label: t("columns.campaign"), - // eslint-disable-next-line react/destructuring-assignment - content: (d) => , + content: (screen) => , }, ]; @@ -57,9 +36,7 @@ function getScreenColumns({ columns.push({ path: "status", label: t("columns.status"), - content: (screen) => { - return ; - }, + content: (screen) => , }); } diff --git a/assets/admin/components/screen/util/screen-groups-button.jsx b/assets/admin/components/screen/util/screen-groups-button.jsx new file mode 100644 index 000000000..dbd8d01b5 --- /dev/null +++ b/assets/admin/components/screen/util/screen-groups-button.jsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import idFromUrl from "../../util/helpers/id-from-url"; +import getAllPages from "../../util/helpers/get-all-pages.js"; +import { Button } from "react-bootstrap"; +import useModal from "../../../context/modal-context/modal-context-hook.jsx"; +import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; +import { useDispatch } from "react-redux"; +import { Link } from "react-router-dom"; + +function ScreenGroupsButton({ screen }) { + const { t } = useTranslation("common", { keyPrefix: "screen-columns" }); + const { setModal } = useModal(); + const dispatch = useDispatch(); + + const onClick = () => { + getAllPages(dispatch, enhancedApi.endpoints.getV2ScreensByIdScreenGroups, { + id: idFromUrl(screen.id), + }).then((groups) => { + const content = ( +
    + {groups.map((group) => ( +
  • + + {group.title} + +
  • + ))} +
+ ); + + setModal({ + info: true, + modalTitle: t("screen-groups-modal-title"), + content, + }); + }); + }; + + return ( + + ); +} + +export default ScreenGroupsButton; diff --git a/assets/admin/components/util/helpers/get-all-pages.js b/assets/admin/components/util/helpers/get-all-pages.js new file mode 100644 index 000000000..34524ba5b --- /dev/null +++ b/assets/admin/components/util/helpers/get-all-pages.js @@ -0,0 +1,27 @@ +const MAX_PAGES = 100; + +async function getAllPages(dispatch, endpoint, params) { + const results = []; + let page = 1; + + while (page <= MAX_PAGES) { + const { data } = await dispatch(endpoint.initiate({ ...params, page })); + const members = data["hydra:member"]; + + if (members.length === 0) { + break; + } + + results.push(...members); + + const hydraView = data["hydra:view"] ?? null; + if (hydraView === null || !(hydraView["hydra:next"] ?? false)) { + break; + } + page += 1; + } + + return results; +} + +export default getAllPages; diff --git a/assets/admin/context/modal-context/info-modal.jsx b/assets/admin/context/modal-context/info-modal.jsx index aaa0e8c19..1ef774279 100644 --- a/assets/admin/context/modal-context/info-modal.jsx +++ b/assets/admin/context/modal-context/info-modal.jsx @@ -25,6 +25,7 @@ function InfoModal({ modalTitle, dataKey = "", redirectTo, + content, }) { const { t } = useTranslation("common"); const [fetchedData, setFetchedData] = useState([]); @@ -55,29 +56,32 @@ function InfoModal({ showAcceptButton={false} declineText={t("info-modal.decline-text")} > -
    - <> - {Array.isArray(displayData) && - displayData.map((displayItem) => ( - + <> + {content} +
      + <> + {Array.isArray(displayData) && + displayData.map((displayItem) => ( + + ))} + {fetchedData.map((item) => ( +
    • + + {item.title} + +
    • ))} - {fetchedData.map((item) => ( -
    • - - {item.title} - -
    • - ))} - -
    + +
+ ); diff --git a/assets/admin/context/modal-context/modal-provider.jsx b/assets/admin/context/modal-context/modal-provider.jsx index b9cf8d4c1..95c0dd3a2 100644 --- a/assets/admin/context/modal-context/modal-provider.jsx +++ b/assets/admin/context/modal-context/modal-provider.jsx @@ -39,6 +39,7 @@ function ModalProvider({ children }) { dataKey={modal.dataKey} redirectTo={modal.redirectTo} unSetModal={unSetModal} + content={modal.content} /> )} diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json index 5ec02fbfd..2ae6cba56 100644 --- a/assets/admin/translations/da/common.json +++ b/assets/admin/translations/da/common.json @@ -86,9 +86,11 @@ "ok": "Aktiv" }, "screen-list": { + "overridden-by-campaign": "Ja", + "not-overridden-by-campaign": "", "columns": { - "campaign": "Kampagne", - "on-groups": "Tilknyttede grupper", + "campaign": "Kampagner", + "on-groups": "Grupper", "location": "Lokation", "status": "Status" }, @@ -518,7 +520,8 @@ "publishing-from": "Udgivelse fra", "publishing-to": "Udgivelse til", "status": "Status", - "number-of-slides": "Slides tilknyttede" + "number-of-slides": "Slides tilknyttede", + "playlist-slide-modal-title": "Slides" }, "shared-playlists-columns": { "name": "Navn", @@ -1141,10 +1144,6 @@ "saving-activation-code": "Opretter aktiveringskode" } }, - "campaign-icon": { - "overridden-by-campaign": "Ja", - "not-overridden-by-campaign": "" - }, "published-state": { "active": "Aktiv", "future": "Fremtidig", @@ -1246,5 +1245,10 @@ "insert-hard-break": "Ny linje", "redo": "Gentag", "undo": "Fortryd" + }, + "screen-columns": { + "screen-groups-modal-title": "Skærmgrupper", + "campaigns-modal-title": "Kampagner", + "active": "Aktiv" } } diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js index ba2f7a175..0cc41a588 100644 --- a/assets/client/service/release-service.js +++ b/assets/client/service/release-service.js @@ -1,4 +1,3 @@ -import ReleaseLoader from "../util/release-loader"; import ClientConfigLoader from "../util/client-config-loader.js"; import defaults from "../util/defaults"; import idFromPath from "../util/id-from-path"; @@ -6,6 +5,7 @@ import appStorage from "../util/app-storage"; import logger from "../logger/logger"; import statusService from "./status-service"; import constants from "../util/constants"; +import ReleaseLoader from "../../shared/release-loader.js"; class ReleaseService { releaseCheckInterval = null; diff --git a/assets/client/util/release-loader.js b/assets/client/util/release-loader.js deleted file mode 100644 index e6ec48bbc..000000000 --- a/assets/client/util/release-loader.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Release loader. - */ -export default class ReleaseLoader { - static async loadRelease() { - const nowTimestamp = new Date().getTime(); - return fetch(`/release.json?ts=${nowTimestamp}`) - .then((response) => response.json()) - .catch((err) => { - /* eslint-disable-next-line no-console */ - console.warn("Could not find release.json. Returning defaults.", err); - - return { - releaseTimestamp: null, - releaseVersion: null, - }; - }); - } -} diff --git a/assets/shared/redux/generated-api.ts b/assets/shared/redux/generated-api.ts index 4e3282eab..63959ac3e 100644 --- a/assets/shared/redux/generated-api.ts +++ b/assets/shared/redux/generated-api.ts @@ -1950,6 +1950,7 @@ export type PlaylistJsonldCampaignsScreenGroupsRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsRead | null; tenants?: CollectionJsonldCampaignsScreenGroupsRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -1971,6 +1972,7 @@ export type PlaylistJsonldCampaignsScreenGroupsReadRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsReadRead | null; tenants?: CollectionJsonldCampaignsScreenGroupsReadRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -1979,6 +1981,8 @@ export type ScreenGroupJsonldCampaignsScreenGroupsRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { @@ -1995,6 +1999,8 @@ export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = { @@ -2030,6 +2036,7 @@ export type PlaylistJsonldCampaignsScreensRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreensRead | null; tenants?: CollectionJsonldCampaignsScreensRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2051,6 +2058,7 @@ export type PlaylistJsonldCampaignsScreensReadRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreensReadRead | null; tenants?: CollectionJsonldCampaignsScreensReadRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2068,6 +2076,9 @@ export type ScreenJsonldCampaignsScreensRead = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; relationsChecksum?: object; }; export type ScreenJsonldCampaignsScreensReadRead = { @@ -2093,6 +2104,9 @@ export type ScreenJsonldCampaignsScreensReadRead = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; relationsChecksum?: object; }; export type ScreenCampaignJsonldCampaignsScreensRead = { @@ -2116,6 +2130,7 @@ export type PlaylistPlaylistJsonld = { campaignScreenGroups?: CollectionJsonld | null; tenants?: CollectionJsonld | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; modifiedBy?: string; createdBy?: string; @@ -2142,6 +2157,7 @@ export type PlaylistPlaylistJsonldRead = { campaignScreenGroups?: CollectionJsonldRead | null; tenants?: CollectionJsonldRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; modifiedBy?: string; createdBy?: string; @@ -2207,6 +2223,8 @@ export type ScreenGroupScreenGroupJsonld = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2228,6 +2246,8 @@ export type ScreenGroupScreenGroupJsonldRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2261,6 +2281,9 @@ export type ScreenScreenJsonld = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2291,6 +2314,9 @@ export type ScreenScreenJsonldRead = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2334,6 +2360,7 @@ export type PlaylistJsonldPlaylistScreenRegionRead = { campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionRead | null; tenants?: CollectionJsonldPlaylistScreenRegionRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2355,6 +2382,7 @@ export type PlaylistJsonldPlaylistScreenRegionReadRead = { campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionReadRead | null; tenants?: CollectionJsonldPlaylistScreenRegionReadRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2375,6 +2403,8 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { @@ -2384,6 +2414,8 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type SlideSlideJsonld = { diff --git a/assets/shared/release-loader.js b/assets/shared/release-loader.js new file mode 100644 index 000000000..c93298d8c --- /dev/null +++ b/assets/shared/release-loader.js @@ -0,0 +1,83 @@ +const DEFAULT_FETCH_INTERVAL = 5 * 60 * 1000; + +const DEFAULT_RELEASE = { + releaseTime: null, + releaseTimestamp: null, + releaseVersion: null, +}; + +class ReleaseLoader { + #releaseData = null; + #latestFetchTimestamp = null; + #activePromise = null; + #fetchFn; + #nowFn; + #fetchInterval; + + /** + * @param {object} options + * @param {Function} options.fetchFn - Fetch implementation. Defaults to global fetch. + * @param {Function} options.nowFn - Returns current time in ms. Defaults to Date.now. + * @param {number} options.fetchInterval - Cache lifetime in ms. Defaults to 5 minutes. + */ + constructor({ + fetchFn = (...args) => fetch(...args), + nowFn = () => Date.now(), + fetchInterval = DEFAULT_FETCH_INTERVAL, + } = {}) { + this.#fetchFn = fetchFn; + this.#nowFn = nowFn; + this.#fetchInterval = fetchInterval; + } + + async loadRelease() { + if (this.#activePromise !== null) { + return this.#activePromise; + } + + const nowTimestamp = this.#nowFn(); + + // Return early without going through activePromise so the caller always + // receives a real promise, not null. + if ( + this.#latestFetchTimestamp !== null && + this.#latestFetchTimestamp + this.#fetchInterval >= nowTimestamp + ) { + return Promise.resolve(this.#releaseData); + } + + this.#activePromise = this.#fetchFn(`/release.json?t=${nowTimestamp}`) + .then((response) => response.json()) + .then((data) => { + this.#latestFetchTimestamp = nowTimestamp; + this.#releaseData = data; + return this.#releaseData; + }) + .catch(() => { + if (this.#releaseData !== null) { + // Advance the timestamp so the next call uses the cache instead of + // immediately retrying after a failed fetch. + this.#latestFetchTimestamp = nowTimestamp; + return this.#releaseData; + } + + /* eslint-disable-next-line no-console */ + console.warn("Could not find release.json. Returning defaults."); + + return DEFAULT_RELEASE; + }) + .finally(() => { + // Always clear activePromise via finally so concurrent callers share a + // single in-flight fetch. It is cleared on both success and failure. + this.#activePromise = null; + }); + + return this.#activePromise; + } +} + +// Default singleton for production use. +const releaseLoader = new ReleaseLoader(); +export default releaseLoader; + +export { ReleaseLoader }; diff --git a/assets/tests/admin/campaigns-button-promise-chain.spec.js b/assets/tests/admin/campaigns-button-promise-chain.spec.js new file mode 100644 index 000000000..1ec17d343 --- /dev/null +++ b/assets/tests/admin/campaigns-button-promise-chain.spec.js @@ -0,0 +1,140 @@ +import { test, expect } from "@playwright/test"; + +/** + * Regression tests for the CampaignsButton.onClick promise chain. + * + * The onClick handler chains nested .then() calls. Inner promises must be + * returned so that rejections propagate to the outer .catch() and + * setLoading(false) is always called. Without the returns, a rejection in + * any inner call (getAllScreenGroupCampaigns, second getAllPages, + * getAllCampaigns) leaves the button in a permanent loading state. + * + * These tests replicate the promise structure from campaigns-button.jsx + * with controllable mocks. + */ + +/** + * Replicates the onClick promise chain from CampaignsButton. + * Inner promises are returned so rejections propagate to the outer .catch(). + */ +function onClick({ + getAllPagesScreenGroups, + getAllScreenGroupCampaigns, + getAllPagesScreenCampaigns, + getAllCampaigns, + setLoading, + setCampaigns, +}) { + setLoading(true); + + getAllPagesScreenGroups() + .then((screenGroups) => { + const screenGroupIds = screenGroups + .filter(({ campaignsLength }) => campaignsLength > 0) + .map((group) => group.id); + + return getAllScreenGroupCampaigns(screenGroupIds).then( + (screenGroupCampaigns) => { + return getAllPagesScreenCampaigns().then((screenCampaigns) => { + const campaignIds = [ + ...screenGroupCampaigns, + ...screenCampaigns, + ].map((c) => c.id); + + return getAllCampaigns(campaignIds).then((allCampaigns) => { + setCampaigns(allCampaigns); + setLoading(false); + }); + }); + }, + ); + }) + .catch(() => setLoading(false)); +} + +function createMocks({ failAt } = {}) { + const state = { loading: false, campaigns: [] }; + + return { + state, + setLoading: (v) => { + state.loading = v; + }, + setCampaigns: (v) => { + state.campaigns = v; + }, + getAllPagesScreenGroups: + failAt === "screenGroups" + ? () => Promise.reject(new Error("screenGroups failed")) + : () => + Promise.resolve([ + { id: "group1", campaignsLength: 2, "@id": "/v2/groups/group1" }, + ]), + getAllScreenGroupCampaigns: + failAt === "screenGroupCampaigns" + ? () => Promise.reject(new Error("screenGroupCampaigns failed")) + : () => + Promise.resolve([ + { id: "campaign1", campaign: { "@id": "/v2/playlists/c1" } }, + ]), + getAllPagesScreenCampaigns: + failAt === "screenCampaigns" + ? () => Promise.reject(new Error("screenCampaigns failed")) + : () => + Promise.resolve([ + { id: "campaign2", campaign: { "@id": "/v2/playlists/c2" } }, + ]), + getAllCampaigns: + failAt === "allCampaigns" + ? () => Promise.reject(new Error("allCampaigns failed")) + : (ids) => Promise.resolve(ids.map((id) => ({ "@id": id, title: id }))), + }; +} + +test.describe("CampaignsButton onClick promise chain", () => { + test("happy path resolves and clears loading", async () => { + const mocks = createMocks(); + onClick(mocks); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mocks.state.loading).toBe(false); + expect(mocks.state.campaigns.length).toBeGreaterThan(0); + }); + + test("rejection in getAllPagesScreenGroups clears loading", async () => { + const mocks = createMocks({ failAt: "screenGroups" }); + onClick(mocks); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mocks.state.loading).toBe(false); + }); + + test("rejection in getAllScreenGroupCampaigns clears loading", async () => { + const mocks = createMocks({ failAt: "screenGroupCampaigns" }); + onClick(mocks); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mocks.state.loading).toBe(false); + }); + + test("rejection in getAllPagesScreenCampaigns clears loading", async () => { + const mocks = createMocks({ failAt: "screenCampaigns" }); + onClick(mocks); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mocks.state.loading).toBe(false); + }); + + test("rejection in getAllCampaigns clears loading", async () => { + const mocks = createMocks({ failAt: "allCampaigns" }); + onClick(mocks); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mocks.state.loading).toBe(false); + }); +}); diff --git a/assets/tests/admin/get-all-pages.spec.js b/assets/tests/admin/get-all-pages.spec.js new file mode 100644 index 000000000..25b715887 --- /dev/null +++ b/assets/tests/admin/get-all-pages.spec.js @@ -0,0 +1,129 @@ +import { test, expect } from "@playwright/test"; +import getAllPages from "../../admin/components/util/helpers/get-all-pages.js"; + +function createHydraResponse(members, hasNext = false) { + return { + data: { + "hydra:member": members, + "hydra:view": hasNext ? { "hydra:next": "/next" } : null, + }, + }; +} + +function createMockEndpoint() { + return { initiate: (params) => params }; +} + +function createMockDispatch(responses) { + let callIndex = 0; + const fn = () => { + const response = responses[callIndex]; + callIndex += 1; + return Promise.resolve(response); + }; + fn.getCallCount = () => callIndex; + return fn; +} + +test.describe("getAllPages", () => { + test("It returns results from a single page", async () => { + const dispatch = createMockDispatch([ + createHydraResponse([{ id: 1 }, { id: 2 }]), + ]); + + const result = await getAllPages(dispatch, createMockEndpoint(), {}); + + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + expect(dispatch.getCallCount()).toBe(1); + }); + + test("It fetches multiple pages when hydra:next is present", async () => { + const dispatch = createMockDispatch([ + createHydraResponse([{ id: 1 }], true), + createHydraResponse([{ id: 2 }], true), + createHydraResponse([{ id: 3 }], false), + ]); + + const result = await getAllPages(dispatch, createMockEndpoint(), {}); + + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + expect(dispatch.getCallCount()).toBe(3); + }); + + test("It passes page number and params to endpoint", async () => { + const calls = []; + const endpoint = { + initiate: (params) => { + calls.push(params); + return params; + }, + }; + const dispatch = createMockDispatch([ + createHydraResponse([{ id: 1 }], true), + createHydraResponse([{ id: 2 }], false), + ]); + + await getAllPages(dispatch, endpoint, { itemsPerPage: 10 }); + + expect(calls).toEqual([ + { itemsPerPage: 10, page: 1 }, + { itemsPerPage: 10, page: 2 }, + ]); + }); + + test("It stops when hydra:view is null", async () => { + const dispatch = createMockDispatch([ + { + data: { + "hydra:member": [{ id: 1 }], + "hydra:view": undefined, + }, + }, + ]); + + const result = await getAllPages(dispatch, createMockEndpoint(), {}); + + expect(result).toEqual([{ id: 1 }]); + expect(dispatch.getCallCount()).toBe(1); + }); + + test("It stops when a page returns empty results", async () => { + const dispatch = createMockDispatch([ + createHydraResponse([{ id: 1 }], true), + createHydraResponse([], true), + ]); + + const result = await getAllPages(dispatch, createMockEndpoint(), {}); + + expect(result).toEqual([{ id: 1 }]); + expect(dispatch.getCallCount()).toBe(2); + }); + + test("It respects the max pages limit", async () => { + const responses = Array.from({ length: 101 }, (_, i) => + createHydraResponse([{ id: i }], true), + ); + const dispatch = createMockDispatch(responses); + + const result = await getAllPages(dispatch, createMockEndpoint(), {}); + + expect(result).toHaveLength(100); + expect(dispatch.getCallCount()).toBe(100); + }); + + test("It propagates fetch errors", async () => { + const dispatch = () => Promise.reject(new Error("Network error")); + + await expect( + getAllPages(dispatch, createMockEndpoint(), {}), + ).rejects.toThrow("Network error"); + }); + + test("It returns empty array when first page has no results", async () => { + const dispatch = createMockDispatch([createHydraResponse([])]); + + const result = await getAllPages(dispatch, createMockEndpoint(), {}); + + expect(result).toEqual([]); + }); +}); diff --git a/assets/tests/shared/release-loader.spec.js b/assets/tests/shared/release-loader.spec.js new file mode 100644 index 000000000..98adb7475 --- /dev/null +++ b/assets/tests/shared/release-loader.spec.js @@ -0,0 +1,125 @@ +import { test, expect } from "@playwright/test"; +import { ReleaseLoader } from "../../shared/release-loader.js"; + +const RELEASE_DATA = { + releaseTime: "2024-01-01T00:00:00Z", + releaseTimestamp: 1704067200, + releaseVersion: "1.0.0", +}; + +function createMockFetch(response = RELEASE_DATA) { + return () => + Promise.resolve({ + json: () => Promise.resolve(response), + }); +} + +function createFailingFetch() { + return () => Promise.reject(new Error("Network error")); +} + +test.describe("ReleaseLoader", () => { + test("It fetches and returns release data", async () => { + const loader = new ReleaseLoader({ fetchFn: createMockFetch() }); + const result = await loader.loadRelease(); + + expect(result).toEqual(RELEASE_DATA); + }); + + test("It returns cached data within the fetch interval", async () => { + let fetchCount = 0; + const fetchFn = () => { + fetchCount += 1; + return createMockFetch()(); + }; + + const loader = new ReleaseLoader({ fetchFn, nowFn: () => 1000 }); + + await loader.loadRelease(); + await loader.loadRelease(); + + expect(fetchCount).toBe(1); + }); + + test("It fetches again after the interval has passed", async () => { + let fetchCount = 0; + const fetchFn = () => { + fetchCount += 1; + return createMockFetch()(); + }; + + let now = 0; + const loader = new ReleaseLoader({ + fetchFn, + nowFn: () => now, + fetchInterval: 1000, + }); + + await loader.loadRelease(); + now = 1001; + await loader.loadRelease(); + + expect(fetchCount).toBe(2); + }); + + test("It returns defaults when fetch fails and no cached data exists", async () => { + const loader = new ReleaseLoader({ fetchFn: createFailingFetch() }); + const result = await loader.loadRelease(); + + expect(result).toEqual({ + releaseTime: null, + releaseTimestamp: null, + releaseVersion: null, + }); + }); + + test("It returns cached data when fetch fails after a successful fetch", async () => { + let shouldFail = false; + const fetchFn = () => { + if (shouldFail) { + return createFailingFetch()(); + } + return createMockFetch()(); + }; + + let now = 0; + const loader = new ReleaseLoader({ + fetchFn, + nowFn: () => now, + fetchInterval: 1000, + }); + + await loader.loadRelease(); + + shouldFail = true; + now = 1001; + const result = await loader.loadRelease(); + + expect(result).toEqual(RELEASE_DATA); + }); + + test("It deduplicates concurrent calls", async () => { + let fetchCount = 0; + let resolveResponse; + + const fetchFn = () => { + fetchCount += 1; + return new Promise((resolve) => { + resolveResponse = resolve; + }); + }; + + const loader = new ReleaseLoader({ fetchFn }); + + const promise1 = loader.loadRelease(); + const promise2 = loader.loadRelease(); + + resolveResponse({ json: () => Promise.resolve(RELEASE_DATA) }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(fetchCount).toBe(1); + expect(result1).toEqual(RELEASE_DATA); + expect(result2).toEqual(RELEASE_DATA); + }); +}); diff --git a/fixtures/playlist.yaml b/fixtures/playlist.yaml index 93ea8ffa5..be710dfd3 100644 --- a/fixtures/playlist.yaml +++ b/fixtures/playlist.yaml @@ -15,7 +15,6 @@ App\Entity\Tenant\Playlist: isCampaign: true playlistSlides: - "@playlist_slide_abc_1" - - "@playlist_slide_abc_2" - "@playlist_slide_abc_3" - "@playlist_slide_abc_4" - "@playlist_slide_abc_5" diff --git a/fixtures/playlist_slide.yaml b/fixtures/playlist_slide.yaml index 644455dfc..cdb92c36f 100644 --- a/fixtures/playlist_slide.yaml +++ b/fixtures/playlist_slide.yaml @@ -15,7 +15,7 @@ App\Entity\Tenant\PlaylistSlide: slide: "@slide_abc_1" tenant: "@tenant_abc" playlist_slide_abc_{3..10} (extends playlist_slide): - playlist: "@playlist_abc_3" + playlist: "@playlist_abc_1" slide: "@slide_abc_" tenant: "@tenant_abc" playlist_slide_abc_{11..40} (extends playlist_slide): diff --git a/infrastructure/build-n-push.sh b/infrastructure/build-n-push.sh index feae48ab7..dc133e842 100755 --- a/infrastructure/build-n-push.sh +++ b/infrastructure/build-n-push.sh @@ -12,6 +12,8 @@ docker buildx build \ --no-cache \ --pull \ --build-arg APP_VERSION=${APP_VERSION} \ + --build-arg APP_RELEASE_TIME="$(date)" \ + --build-arg APP_RELEASE_TIMESTAMP="$(date +%s)" \ --tag=ghcr.io/itk-dev/os2display-api-service:${APP_VERSION} \ --file="display-api-service/Dockerfile" ../ diff --git a/infrastructure/display-api-service/Dockerfile b/infrastructure/display-api-service/Dockerfile index 1f0d6db63..3d00a6beb 100644 --- a/infrastructure/display-api-service/Dockerfile +++ b/infrastructure/display-api-service/Dockerfile @@ -29,6 +29,8 @@ FROM --platform=$BUILDPLATFORM itkdev/php8.4-fpm:latest AS api_app_builder LABEL maintainer="ITK Dev " ARG APP_VERSION="develop" +ARG APP_RELEASE_TIMESTAMP=0 +ARG APP_RELEASE_TIME="" WORKDIR /app @@ -40,6 +42,9 @@ RUN APP_ENV=prod composer install --no-dev --optimize-autoloader --classmap-auth COPY --chown=deploy:deploy --from=assets_builder /app/public/build /app/public/build +# Create release.json file in public folder +RUN echo "{\"releaseTimestamp\": $APP_RELEASE_TIMESTAMP, \"releaseTime\": \"$APP_RELEASE_TIME\", \"releaseVersion\": \"$APP_VERSION\"}" > public/release.json + # Remove files we do not need to the final image RUN rm -rf package* vite.config.js diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index 0a05b36eb..df8506599 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -9175,6 +9175,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9268,6 +9274,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9343,6 +9355,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9418,6 +9436,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9503,6 +9527,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9578,6 +9608,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9653,6 +9689,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9728,6 +9770,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -9856,6 +9904,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10068,6 +10122,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10196,6 +10256,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10306,6 +10372,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10416,6 +10488,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10571,6 +10649,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10681,6 +10765,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -10791,6 +10881,12 @@ "isCampaign": { "type": "boolean" }, + "slidesLength": { + "type": [ + "integer", + "null" + ] + }, "published": { "default": { "from": "", @@ -11471,6 +11567,24 @@ "type": "string" } }, + "activeCampaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "inScreenGroupsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -11555,6 +11669,24 @@ "type": "string" } }, + "activeCampaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "inScreenGroupsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -11631,6 +11763,24 @@ "type": "string" } }, + "activeCampaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "inScreenGroupsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -11750,6 +11900,24 @@ "type": "string" } }, + "activeCampaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "inScreenGroupsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -11973,6 +12141,24 @@ "type": "string" } }, + "activeCampaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "inScreenGroupsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12092,6 +12278,24 @@ "type": "string" } }, + "activeCampaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, + "inScreenGroupsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12541,6 +12745,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12581,6 +12797,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12603,6 +12831,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12635,6 +12875,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12657,6 +12909,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12707,6 +12971,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12764,6 +13040,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12830,6 +13118,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12913,6 +13213,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12988,6 +13300,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -13045,6 +13369,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -13093,6 +13429,18 @@ "screens": { "type": "string" }, + "screensLength": { + "type": [ + "integer", + "null" + ] + }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 687d694aa..9c1b21eb1 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -6476,6 +6476,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6538,6 +6542,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6587,6 +6595,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6636,6 +6648,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6692,6 +6708,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6741,6 +6761,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6790,6 +6814,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6839,6 +6867,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -6924,6 +6956,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7067,6 +7103,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7152,6 +7192,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7224,6 +7268,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7296,6 +7344,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7398,6 +7450,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7470,6 +7526,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -7542,6 +7602,10 @@ components: type: 'null' isCampaign: type: boolean + slidesLength: + type: + - integer + - 'null' published: default: from: '' @@ -8017,6 +8081,18 @@ components: - 'null' items: type: string + activeCampaignsLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' + inScreenGroupsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8075,6 +8151,18 @@ components: - 'null' items: type: string + activeCampaignsLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' + inScreenGroupsLength: + type: + - integer + - 'null' relationsChecksum: type: object Screen-screen-campaigns.read: @@ -8127,6 +8215,18 @@ components: - 'null' items: type: string + activeCampaignsLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' + inScreenGroupsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8208,6 +8308,18 @@ components: - 'null' items: type: string + activeCampaignsLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' + inScreenGroupsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8359,6 +8471,18 @@ components: - 'null' items: type: string + activeCampaignsLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' + inScreenGroupsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8440,6 +8564,18 @@ components: - 'null' items: type: string + activeCampaignsLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' + inScreenGroupsLength: + type: + - integer + - 'null' relationsChecksum: type: object Screen.jsonld-screen-campaigns.read: @@ -8755,6 +8891,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8783,6 +8927,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup-screen-groups.campaigns.read: @@ -8798,6 +8950,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup-screen-groups.screens.read: @@ -8820,6 +8980,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.ScreenGroup: @@ -8835,6 +9003,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8870,6 +9046,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.ScreenGroup.jsonld: @@ -8908,6 +9092,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8955,6 +9147,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.ScreenGroupInput: @@ -9011,6 +9211,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -9062,6 +9270,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.jsonld-screen-groups.campaigns.read: @@ -9100,6 +9316,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.jsonld-screen-groups.screens.read: @@ -9134,6 +9358,14 @@ components: type: string screens: type: string + screensLength: + type: + - integer + - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroupCampaign: diff --git a/src/Dto/Playlist.php b/src/Dto/Playlist.php index 7c63c49c7..01764b6f6 100644 --- a/src/Dto/Playlist.php +++ b/src/Dto/Playlist.php @@ -43,6 +43,9 @@ class Playlist #[Groups(['playlist-screen-region:read', 'screen-campaigns:read', 'campaigns/screens:read', 'slides/playlists:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])] public bool $isCampaign; + #[Groups(['playlist-screen-region:read', 'screen-campaigns:read', 'campaigns/screens:read', 'slides/playlists:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])] + public ?int $slidesLength = null; + #[Groups(['playlist-screen-region:read', 'screen-campaigns:read', 'campaigns/screens:read', 'slides/playlists:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])] public array $published = [ 'from' => '', diff --git a/src/Dto/Screen.php b/src/Dto/Screen.php index 825a013af..97e089dea 100644 --- a/src/Dto/Screen.php +++ b/src/Dto/Screen.php @@ -55,4 +55,13 @@ class Screen #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])] public ?array $status = null; + + #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])] + public ?int $activeCampaignsLength = null; + + #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])] + public ?int $campaignsLength = null; + + #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])] + public ?int $inScreenGroupsLength = null; } diff --git a/src/Dto/ScreenGroup.php b/src/Dto/ScreenGroup.php index b301db41f..2d2410e8d 100644 --- a/src/Dto/ScreenGroup.php +++ b/src/Dto/ScreenGroup.php @@ -28,4 +28,10 @@ class ScreenGroup #[Groups(['screens/screen-groups:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])] public string $screens = ''; + + #[Groups(['screens/screen-groups:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])] + public ?int $screensLength = null; + + #[Groups(['screens/screen-groups:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])] + public ?int $campaignsLength = null; } diff --git a/src/Entity/ScreenLayout.php b/src/Entity/ScreenLayout.php index 89b66e5af..ca235edf7 100644 --- a/src/Entity/ScreenLayout.php +++ b/src/Entity/ScreenLayout.php @@ -33,13 +33,13 @@ class ScreenLayout extends AbstractBaseEntity implements MultiTenantInterface, R /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Screen::class, mappedBy: 'screenLayout')] + #[ORM\OneToMany(mappedBy: 'screenLayout', targetEntity: Screen::class, fetch: 'EXTRA_LAZY')] private Collection $screens; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: ScreenLayoutRegions::class, mappedBy: 'screenLayout')] + #[ORM\OneToMany(targetEntity: ScreenLayoutRegions::class, fetch: 'EXTRA_LAZY', mappedBy: 'screenLayout')] private Collection $regions; public function __construct() diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php index 0c5466511..a95711a2b 100644 --- a/src/Entity/ScreenLayoutRegions.php +++ b/src/Entity/ScreenLayoutRegions.php @@ -41,7 +41,7 @@ class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInter /** * @var Collection */ - #[ORM\OneToMany(targetEntity: PlaylistScreenRegion::class, cascade: ['persist', 'remove'], mappedBy: 'region', orphanRemoval: true)] + #[ORM\OneToMany(targetEntity: PlaylistScreenRegion::class, fetch: 'EXTRA_LAZY', cascade: ['persist', 'remove'], mappedBy: 'region', orphanRemoval: true)] private Collection $playlistScreenRegions; public function __construct() diff --git a/src/Entity/Template.php b/src/Entity/Template.php index cae53c7e4..9eea0f8dd 100644 --- a/src/Entity/Template.php +++ b/src/Entity/Template.php @@ -30,7 +30,7 @@ class Template extends AbstractBaseEntity implements MultiTenantInterface, Relat /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Slide::class, mappedBy: 'template')] + #[ORM\OneToMany(mappedBy: 'template', targetEntity: Slide::class, fetch: 'EXTRA_LAZY')] private Collection $slides; public function __construct() diff --git a/src/Entity/Tenant.php b/src/Entity/Tenant.php index 91008f673..4a486763a 100644 --- a/src/Entity/Tenant.php +++ b/src/Entity/Tenant.php @@ -22,7 +22,7 @@ class Tenant extends AbstractBaseEntity implements \JsonSerializable /** * @var Collection */ - #[ORM\OneToMany(targetEntity: UserRoleTenant::class, mappedBy: 'tenant', orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'tenant', targetEntity: UserRoleTenant::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $userRoleTenants; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true)] diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php index b580bfe7d..d730b13a4 100644 --- a/src/Entity/Tenant/FeedSource.php +++ b/src/Entity/Tenant/FeedSource.php @@ -29,7 +29,7 @@ class FeedSource extends AbstractTenantScopedEntity implements RelationsChecksum /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Feed::class, mappedBy: 'feedSource', orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'feedSource', targetEntity: Feed::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $feeds; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php index 442ccade6..7ba0883f5 100644 --- a/src/Entity/Tenant/Media.php +++ b/src/Entity/Tenant/Media.php @@ -52,7 +52,7 @@ class Media extends AbstractTenantScopedEntity implements RelationsChecksumInter /** * @var Collection */ - #[ORM\ManyToMany(targetEntity: Slide::class, mappedBy: 'media')] + #[ORM\ManyToMany(targetEntity: Slide::class, mappedBy: 'media', fetch: 'EXTRA_LAZY')] private Collection $slides; public function __construct() diff --git a/src/Entity/Tenant/Playlist.php b/src/Entity/Tenant/Playlist.php index d565ff84a..4504cbc85 100644 --- a/src/Entity/Tenant/Playlist.php +++ b/src/Entity/Tenant/Playlist.php @@ -31,32 +31,32 @@ class Playlist extends AbstractTenantScopedEntity implements MultiTenantInterfac /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenCampaign::class, orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $screenCampaigns; /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenGroupCampaign::class, orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenGroupCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $screenGroupCampaigns; /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistScreenRegion::class, orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistScreenRegion::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $playlistScreenRegions; /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistSlide::class, orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistSlide::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[ORM\OrderBy(['weight' => Order::Ascending->value])] private Collection $playlistSlides; /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: Schedule::class, cascade: ['persist'], orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: Schedule::class, cascade: ['persist'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $schedules; public function __construct() diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php index 9fde5553e..660f59bc8 100644 --- a/src/Entity/Tenant/Screen.php +++ b/src/Entity/Tenant/Screen.php @@ -46,20 +46,20 @@ class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInte /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'screen', targetEntity: ScreenCampaign::class, orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'screen', targetEntity: ScreenCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $screenCampaigns; /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'screen', targetEntity: PlaylistScreenRegion::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'screen', targetEntity: PlaylistScreenRegion::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] #[ORM\OrderBy(['weight' => \Doctrine\Common\Collections\Order::Ascending->value])] private Collection $playlistScreenRegions; /** * @var Collection */ - #[ORM\ManyToMany(targetEntity: ScreenGroup::class, mappedBy: 'screens')] + #[ORM\ManyToMany(targetEntity: ScreenGroup::class, mappedBy: 'screens', fetch: 'EXTRA_LAZY')] private Collection $screenGroups; public function __construct() diff --git a/src/Entity/Tenant/ScreenGroup.php b/src/Entity/Tenant/ScreenGroup.php index 228ebe82d..23fa34646 100644 --- a/src/Entity/Tenant/ScreenGroup.php +++ b/src/Entity/Tenant/ScreenGroup.php @@ -22,13 +22,13 @@ class ScreenGroup extends AbstractTenantScopedEntity implements RelationsChecksu /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'screenGroup', targetEntity: ScreenGroupCampaign::class, orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'screenGroup', targetEntity: ScreenGroupCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $screenGroupCampaigns; /** * @var Collection */ - #[ORM\ManyToMany(targetEntity: Screen::class, inversedBy: 'screenGroups')] + #[ORM\ManyToMany(targetEntity: Screen::class, inversedBy: 'screenGroups', fetch: 'EXTRA_LAZY')] private Collection $screens; public function __construct() diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php index 82e1fbd7f..335846915 100644 --- a/src/Entity/Tenant/Slide.php +++ b/src/Entity/Tenant/Slide.php @@ -42,7 +42,7 @@ class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInter /** * @var Collection */ - #[ORM\ManyToMany(targetEntity: Media::class, inversedBy: 'slides')] + #[ORM\ManyToMany(targetEntity: Media::class, inversedBy: 'slides', fetch: 'EXTRA_LAZY')] private Collection $media; /** diff --git a/src/Entity/Tenant/Theme.php b/src/Entity/Tenant/Theme.php index 915e48c4d..a8f8ebdf5 100644 --- a/src/Entity/Tenant/Theme.php +++ b/src/Entity/Tenant/Theme.php @@ -29,7 +29,7 @@ class Theme extends AbstractTenantScopedEntity implements RelationsChecksumInter /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'theme', targetEntity: Slide::class)] + #[ORM\OneToMany(mappedBy: 'theme', targetEntity: Slide::class, fetch: 'EXTRA_LAZY')] private Collection $slides; public function __construct() diff --git a/src/Entity/Traits/MultiTenantTrait.php b/src/Entity/Traits/MultiTenantTrait.php index f05310a44..b99ce2eec 100644 --- a/src/Entity/Traits/MultiTenantTrait.php +++ b/src/Entity/Traits/MultiTenantTrait.php @@ -13,7 +13,7 @@ trait MultiTenantTrait /** * @var Collection */ - #[ORM\ManyToMany(targetEntity: Tenant::class)] + #[ORM\ManyToMany(targetEntity: Tenant::class, fetch: 'EXTRA_LAZY')] private Collection $tenants; /** diff --git a/src/Entity/User.php b/src/Entity/User.php index 6f5b191a9..53b6fdba7 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -42,7 +42,7 @@ class User extends AbstractBaseEntity implements UserInterface, PasswordAuthenti /** * @var Collection */ - #[ORM\OneToMany(targetEntity: UserRoleTenant::class, mappedBy: 'user', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserRoleTenant::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)] private Collection $userRoleTenants; #[ORM\Column(type: Types::STRING)] diff --git a/src/Repository/ScreenRepository.php b/src/Repository/ScreenRepository.php index d469b79fa..d51afcb93 100644 --- a/src/Repository/ScreenRepository.php +++ b/src/Repository/ScreenRepository.php @@ -4,8 +4,13 @@ namespace App\Repository; +use App\Entity\Tenant\Playlist; use App\Entity\Tenant\Screen; +use App\Entity\Tenant\ScreenCampaign; +use App\Entity\Tenant\ScreenGroupCampaign; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\NoResultException; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -32,4 +37,50 @@ public function getScreensByScreenGroupId(Ulid $screenGroupUlid): QueryBuilder return $queryBuilder; } + + /** + * Get total or active campaign counts for a screen via a single SQL query. + * + * Collects campaigns from both direct screen assignments (screen_campaign) + * and indirect assignments through screen groups (screen_group_campaign), + * then counts distinct campaigns and filters for currently active ones + * based on published_from/published_to dates if $active is true. + * + * @return int number of campaigns for the screen + * + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function getCampaignCountForScreen(Ulid $screenId, bool $activeCampaigns = false): int + { + $em = $this->getEntityManager(); + $now = new \DateTime(); + + // Subquery: all campaign IDs for this screen (direct + via groups) + $directQb = $em->createQueryBuilder() + ->select('IDENTITY(sc.campaign)') + ->from(ScreenCampaign::class, 'sc') + ->where('sc.screen = :screenId'); + + $groupQb = $em->createQueryBuilder() + ->select('IDENTITY(sgc.campaign)') + ->from(ScreenGroupCampaign::class, 'sgc') + ->innerJoin('sgc.screenGroup', 'sg') + ->innerJoin('sg.screens', 's') + ->where('s.id = :screenId'); + + $qb = $em->createQueryBuilder() + ->select('COUNT(DISTINCT p.id)') + ->from(Playlist::class, 'p') + ->where('p.id IN ('.$directQb->getDQL().') OR p.id IN ('.$groupQb->getDQL().')') + ->setParameter('screenId', $screenId, 'ulid'); + + if ($activeCampaigns) { + $qb->andWhere('p.publishedFrom IS NULL OR p.publishedFrom < :now') + ->andWhere('p.publishedTo IS NULL OR p.publishedTo > :now') + ->setParameter('now', $now); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } } diff --git a/src/State/PlaylistProvider.php b/src/State/PlaylistProvider.php index 6e0fcf419..1c3a873d7 100644 --- a/src/State/PlaylistProvider.php +++ b/src/State/PlaylistProvider.php @@ -59,6 +59,8 @@ public function toOutput(object $object): PlaylistDTO 'to' => $object->getPublishedTo(), ]; + $output->slidesLength = $object->getPlaylistSlides()->count(); + $output->setRelationsChecksum($object->getRelationsChecksum()); return $output; diff --git a/src/State/ScreenGroupProvider.php b/src/State/ScreenGroupProvider.php index 721926723..a64371bb3 100644 --- a/src/State/ScreenGroupProvider.php +++ b/src/State/ScreenGroupProvider.php @@ -36,6 +36,9 @@ public function toOutput(object $object): ScreenGroupDTO $output->campaigns = $iri.'/campaigns'; $output->screens = $iri.'/screens'; + $output->screensLength = $object->getScreens()->count(); + $output->campaignsLength = $object->getScreenGroupCampaigns()->count(); + $output->setRelationsChecksum($object->getRelationsChecksum()); return $output; diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php index f4015d9cf..977ee7fd3 100644 --- a/src/State/ScreenProvider.php +++ b/src/State/ScreenProvider.php @@ -16,10 +16,10 @@ class ScreenProvider extends AbstractProvider public function __construct( private readonly IriConverterInterface $iriConverter, ProviderInterface $collectionProvider, - ScreenRepository $entityRepository, + private readonly ScreenRepository $screenRepository, private readonly bool $trackScreenInfo = false, ) { - parent::__construct($collectionProvider, $entityRepository); + parent::__construct($collectionProvider, $screenRepository); } public function toOutput(object $object): ScreenDTO @@ -49,11 +49,19 @@ public function toOutput(object $object): ScreenDTO $iri = $this->iriConverter->getIriFromResource($object); $output->campaigns = $iri.'/campaigns'; + $id = $object->getId(); + + if (null !== $id) { + $output->campaignsLength = $this->screenRepository->getCampaignCountForScreen($id); + $output->activeCampaignsLength = $this->screenRepository->getCampaignCountForScreen($id, true); + } + $objectIri = $this->iriConverter->getIriFromResource($object); foreach ($layout->getRegions() as $region) { $output->regions[] = $objectIri.'/regions/'.$region->getId().'/playlists'; } $output->inScreenGroups = $objectIri.'/screen-groups'; + $output->inScreenGroupsLength = $object->getScreenGroups()->count(); $objectUser = $object->getScreenUser(); diff --git a/tests/Api/PlaylistsTest.php b/tests/Api/PlaylistsTest.php index d0fabd239..4559a6cdd 100644 --- a/tests/Api/PlaylistsTest.php +++ b/tests/Api/PlaylistsTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Api; use App\Entity\Tenant\Playlist; +use App\Entity\Tenant\Slide; use App\Tests\AbstractBaseApiTestCase; use Symfony\Component\HttpFoundation\Response; @@ -33,6 +34,20 @@ public function testGetCollection(): void $this->assertCount(5, $response->toArray()['hydra:member']); } + public function testGetCollectionContainsLengthFields(): void + { + $response = $this->getAuthenticatedClient()->request('GET', '/v2/playlists?itemsPerPage=1', ['headers' => ['Content-Type' => 'application/ld+json']]); + + $this->assertResponseIsSuccessful(); + + $members = $response->toArray()['hydra:member']; + $this->assertNotEmpty($members); + + $firstMember = $members[0]; + $this->assertArrayHasKey('slidesLength', $firstMember); + $this->assertIsInt($firstMember['slidesLength']); + } + public function testGetCampaigns(): void { $this->getAuthenticatedClient()->request('GET', '/v2/playlists?itemsPerPage=5&isCampaign=true', ['headers' => ['Content-Type' => 'application/ld+json']]); @@ -388,6 +403,56 @@ public function testGetScreensList(): void ]); } + public function testSlidesLength(): void + { + $client = $this->getAuthenticatedClient(); + + // A newly created playlist has no slides + $response = $client->request('POST', '/v2/playlists', [ + 'json' => ['title' => 'Test slidesLength'], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains(['slidesLength' => 0]); + + $playlistIri = $response->toArray()['@id']; + $playlistUlid = $this->iriHelperUtils->getUlidFromIRI($playlistIri); + + // Verify slidesLength = 0 on GET + $client->request('GET', $playlistIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['slidesLength' => 0]); + + // Add one slide and verify count increases to 1 + $slideIri = $this->findIriBy(Slide::class, ['tenant' => $this->tenant]); + $slideUlid = $this->iriHelperUtils->getUlidFromIRI($slideIri); + + $client->request('PUT', '/v2/playlists/'.$playlistUlid.'/slides', [ + 'json' => [(object) ['slide' => $slideUlid, 'weight' => 0]], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $playlistIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['slidesLength' => 1]); + + // Clean up: remove all slides from the playlist + $client->request('PUT', '/v2/playlists/'.$playlistUlid.'/slides', [ + 'json' => [], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $playlistIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['slidesLength' => 0]); + + $client->request('DELETE', $playlistIri); + $this->assertResponseStatusCodeSame(204); + } + public function testSharedPlaylists(): void { $response = $this->getAuthenticatedClient()->request('GET', '/v2/playlists?itemsPerPage=20', ['headers' => ['Content-Type' => 'application/ld+json']]); diff --git a/tests/Api/ScreenGroupsTest.php b/tests/Api/ScreenGroupsTest.php index 4046955b8..e89a58985 100644 --- a/tests/Api/ScreenGroupsTest.php +++ b/tests/Api/ScreenGroupsTest.php @@ -34,6 +34,22 @@ public function testGetCollection(): void $this->assertMatchesResourceCollectionJsonSchema(ScreenGroup::class); } + public function testGetCollectionContainsLengthFields(): void + { + $response = $this->getAuthenticatedClient('ROLE_SCREEN')->request('GET', '/v2/screen-groups?itemsPerPage=1', ['headers' => ['Content-Type' => 'application/ld+json']]); + + $this->assertResponseIsSuccessful(); + + $members = $response->toArray()['hydra:member']; + $this->assertNotEmpty($members); + + $firstMember = $members[0]; + $this->assertArrayHasKey('screensLength', $firstMember); + $this->assertArrayHasKey('campaignsLength', $firstMember); + $this->assertIsInt($firstMember['screensLength']); + $this->assertIsInt($firstMember['campaignsLength']); + } + public function testGetItem(): void { $client = $this->getAuthenticatedClient('ROLE_SCREEN'); @@ -135,6 +151,114 @@ public function testDeleteScreenGroup(): void ); } + public function testScreensLength(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + // Create a new screen group with no screens + $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [ + 'json' => ['title' => 'Test screensLength group'], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $screenGroupIri = $screenGroupResponse->toArray()['@id']; + $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri); + + // Initial state: screensLength is 0 + $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['screensLength' => 0]); + + // Add an existing screen to the group + $screenIri = $this->findIriBy(Screen::class, ['tenant' => $this->tenant]); + $screenUlid = $this->iriHelperUtils->getUlidFromIRI($screenIri); + + $client->request('PUT', '/v2/screens/'.$screenUlid.'/screen-groups', [ + 'json' => [$screenGroupUlid], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + // screensLength should now be 1 + $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['screensLength' => 1]); + + // Cleanup + $client->request('DELETE', '/v2/screens/'.$screenUlid.'/screen-groups/'.$screenGroupUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $screenGroupIri); + $this->assertResponseStatusCodeSame(204); + } + + public function testCampaignsLength(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + // Create a new screen group with no campaigns + $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [ + 'json' => ['title' => 'Test campaignsLength group'], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $screenGroupIri = $screenGroupResponse->toArray()['@id']; + $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri); + + // Initial state: campaignsLength is 0 + $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'screensLength' => 0, + 'campaignsLength' => 0, + ]); + + // Create a campaign + $campaignResponse = $client->request('POST', '/v2/playlists', [ + 'json' => [ + 'title' => 'Test campaign for group', + 'isCampaign' => true, + 'published' => [ + 'from' => '2020-01-01T00:00:00.000Z', + 'to' => '2099-01-01T00:00:00.000Z', + ], + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $campaignIri = $campaignResponse->toArray()['@id']; + $campaignUlid = $this->iriHelperUtils->getUlidFromIRI($campaignIri); + + // Link the campaign to the screen group + $client->request('PUT', '/v2/screen-groups/'.$campaignUlid.'/campaigns', [ + 'json' => [(object) ['screengroup' => $screenGroupUlid]], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + // campaignsLength should now be 1 + $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['campaignsLength' => 1]); + + // Cleanup + $client->request('DELETE', '/v2/screen-groups/'.$screenGroupUlid.'/campaigns/'.$campaignUlid); + $this->assertResponseStatusCodeSame(204); + + // After removing the campaign, campaignsLength should be 0 again + $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['campaignsLength' => 0]); + + $client->request('DELETE', $screenGroupIri); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $campaignIri); + $this->assertResponseStatusCodeSame(204); + } + public function testGetScreenGroupsScreenRelations(): void { $client = $this->getAuthenticatedClient('ROLE_SCREEN'); diff --git a/tests/Api/ScreensTest.php b/tests/Api/ScreensTest.php index 1ea349674..5e4377e22 100644 --- a/tests/Api/ScreensTest.php +++ b/tests/Api/ScreensTest.php @@ -53,6 +53,24 @@ public function testGetCollection(): void $this->assertMatchesResourceCollectionJsonSchema(Screen::class); } + public function testGetCollectionContainsLengthFields(): void + { + $response = $this->getAuthenticatedClient('ROLE_ADMIN')->request('GET', '/v2/screens?itemsPerPage=1', ['headers' => ['Content-Type' => 'application/ld+json']]); + + $this->assertResponseIsSuccessful(); + + $members = $response->toArray()['hydra:member']; + $this->assertNotEmpty($members); + + $firstMember = $members[0]; + $this->assertArrayHasKey('campaignsLength', $firstMember); + $this->assertArrayHasKey('activeCampaignsLength', $firstMember); + $this->assertArrayHasKey('inScreenGroupsLength', $firstMember); + $this->assertIsInt($firstMember['campaignsLength']); + $this->assertIsInt($firstMember['activeCampaignsLength']); + $this->assertIsInt($firstMember['inScreenGroupsLength']); + } + public function testGetItem(): void { $client = $this->getAuthenticatedClient('ROLE_ADMIN'); @@ -258,6 +276,245 @@ public function testUpdateScreen(): void $this->assertEquals($playlistScreenRegionCountBefore - 1, $playlistScreenRegionCountAfter, 'PlaylistScreenRegion count should go 1 down'); } + public function testCampaignsLengthAndActiveCampaignsLength(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $layoutIri = $this->findIriBy(ScreenLayout::class, ['title' => 'full screen']); + + // Create a fresh screen with no campaigns and no screen groups + $screenResponse = $client->request('POST', '/v2/screens', [ + 'json' => [ + 'title' => 'Test campaignsLength screen', + 'layout' => $layoutIri, + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $screenIri = $screenResponse->toArray()['@id']; + $screenUlid = $this->iriHelperUtils->getUlidFromIRI($screenIri); + + // Initial state: all counts are 0 + $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'campaignsLength' => 0, + 'activeCampaignsLength' => 0, + 'inScreenGroupsLength' => 0, + ]); + + // Create an active campaign: publishedFrom in past, publishedTo in far future + $activeCampaignResponse = $client->request('POST', '/v2/playlists', [ + 'json' => [ + 'title' => 'Active campaign', + 'isCampaign' => true, + 'published' => [ + 'from' => '2020-01-01T00:00:00.000Z', + 'to' => '2099-01-01T00:00:00.000Z', + ], + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $activeCampaignIri = $activeCampaignResponse->toArray()['@id']; + $activeCampaignUlid = $this->iriHelperUtils->getUlidFromIRI($activeCampaignIri); + + // Create an inactive campaign: publishedFrom in far future (not yet started) + $inactiveCampaignResponse = $client->request('POST', '/v2/playlists', [ + 'json' => [ + 'title' => 'Inactive campaign', + 'isCampaign' => true, + 'published' => [ + 'from' => '2099-01-01T00:00:00.000Z', + 'to' => '2099-06-01T00:00:00.000Z', + ], + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $inactiveCampaignIri = $inactiveCampaignResponse->toArray()['@id']; + $inactiveCampaignUlid = $this->iriHelperUtils->getUlidFromIRI($inactiveCampaignIri); + + // Link active campaign to the screen. + // Note: PUT /v2/screens/{campaignId}/campaigns is campaign-centric — {id} is the campaign ULID, + // and the body specifies which screens should display it. + $client->request('PUT', '/v2/screens/'.$activeCampaignUlid.'/campaigns', [ + 'json' => [(object) ['screen' => $screenUlid]], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'campaignsLength' => 1, + 'activeCampaignsLength' => 1, + 'inScreenGroupsLength' => 0, + ]); + + // Link inactive campaign to the screen + $client->request('PUT', '/v2/screens/'.$inactiveCampaignUlid.'/campaigns', [ + 'json' => [(object) ['screen' => $screenUlid]], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'campaignsLength' => 2, + 'activeCampaignsLength' => 1, + 'inScreenGroupsLength' => 0, + ]); + + // Create an empty screen group (no campaigns) and add the screen to it + $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [ + 'json' => ['title' => 'Test screen group for inScreenGroupsLength'], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $screenGroupIri = $screenGroupResponse->toArray()['@id']; + $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri); + + $client->request('PUT', '/v2/screens/'.$screenUlid.'/screen-groups', [ + 'json' => [$screenGroupUlid], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'campaignsLength' => 2, + 'activeCampaignsLength' => 1, + 'inScreenGroupsLength' => 1, + ]); + + // Cleanup + $client->request('DELETE', '/v2/screens/'.$screenUlid.'/campaigns/'.$inactiveCampaignUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', '/v2/screens/'.$screenUlid.'/campaigns/'.$activeCampaignUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', '/v2/screens/'.$screenUlid.'/screen-groups/'.$screenGroupUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $screenIri); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $screenGroupIri); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $activeCampaignIri); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $inactiveCampaignIri); + $this->assertResponseStatusCodeSame(204); + } + + public function testCampaignsLengthViaScreenGroupAndDeduplication(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $layoutIri = $this->findIriBy(ScreenLayout::class, ['title' => 'full screen']); + + // Create a fresh screen + $screenResponse = $client->request('POST', '/v2/screens', [ + 'json' => [ + 'title' => 'Test indirect campaigns screen', + 'layout' => $layoutIri, + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $screenIri = $screenResponse->toArray()['@id']; + $screenUlid = $this->iriHelperUtils->getUlidFromIRI($screenIri); + + // Create a screen group + $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [ + 'json' => ['title' => 'Test indirect campaigns group'], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $screenGroupIri = $screenGroupResponse->toArray()['@id']; + $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri); + + // Create an active campaign + $campaignResponse = $client->request('POST', '/v2/playlists', [ + 'json' => [ + 'title' => 'Indirect campaign', + 'isCampaign' => true, + 'published' => [ + 'from' => '2020-01-01T00:00:00.000Z', + 'to' => '2099-01-01T00:00:00.000Z', + ], + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $campaignIri = $campaignResponse->toArray()['@id']; + $campaignUlid = $this->iriHelperUtils->getUlidFromIRI($campaignIri); + + // Add screen to the screen group + $client->request('PUT', '/v2/screens/'.$screenUlid.'/screen-groups', [ + 'json' => [$screenGroupUlid], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + // Link campaign to the screen group (indirect path) + $client->request('PUT', '/v2/screen-groups/'.$campaignUlid.'/campaigns', [ + 'json' => [(object) ['screengroup' => $screenGroupUlid]], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + // Verify the indirect campaign is counted + $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'campaignsLength' => 1, + 'activeCampaignsLength' => 1, + ]); + + // Now also link the same campaign directly to the screen + $client->request('PUT', '/v2/screens/'.$campaignUlid.'/campaigns', [ + 'json' => [(object) ['screen' => $screenUlid]], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + + // Verify deduplication: campaign is linked both directly and via group, + // but COUNT(DISTINCT) should count it only once + $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'campaignsLength' => 1, + 'activeCampaignsLength' => 1, + ]); + + // Cleanup + $client->request('DELETE', '/v2/screens/'.$screenUlid.'/campaigns/'.$campaignUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', '/v2/screen-groups/'.$screenGroupUlid.'/campaigns/'.$campaignUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', '/v2/screens/'.$screenUlid.'/screen-groups/'.$screenGroupUlid); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $screenIri); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $screenGroupIri); + $this->assertResponseStatusCodeSame(204); + + $client->request('DELETE', $campaignIri); + $this->assertResponseStatusCodeSame(204); + } + public function testDeleteScreen(): void { $client = $this->getAuthenticatedClient('ROLE_ADMIN'); diff --git a/tests/EventListener/RelationsChecksumListenerTest.php b/tests/EventListener/RelationsChecksumListenerTest.php index ab294d678..b814d87c2 100644 --- a/tests/EventListener/RelationsChecksumListenerTest.php +++ b/tests/EventListener/RelationsChecksumListenerTest.php @@ -617,7 +617,7 @@ public function testPlaylistSlideRelation(): void $playlistSlides = $playlist->getPlaylistSlides(); - $this->assertGreaterThanOrEqual(10, $playlistSlides->count(), 'Fixtures count does not match expected value'); + $this->assertGreaterThanOrEqual(9, $playlistSlides->count(), 'Fixtures count does not match expected value'); $checksums = $playlist->getRelationsChecksum(); $this->assertArrayHasKey('slides', $checksums);