From 86617f4d9be7426439ec83bfd34401d801b1b2f0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 23 Sep 2026 21:22:15 +0400 Subject: [PATCH 01/21] Revert "ref: comment out admin data fetching logic in AdminLayout" This reverts commit 026d10cd2331fb801fe07e93ae31439656f3eb4c. --- assets/vue/layouts/AdminLayout.vue | 28 +++++++++---------- .../assets/vue/layouts/AdminLayout.spec.js | 14 ++++++++++ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/assets/vue/layouts/AdminLayout.vue b/assets/vue/layouts/AdminLayout.vue index f33fa19..fc4c847 100644 --- a/assets/vue/layouts/AdminLayout.vue +++ b/assets/vue/layouts/AdminLayout.vue @@ -213,20 +213,20 @@ const closeSearchResultsOnOutsideClick = (event) => { onMounted(async () => { document.addEventListener('click', closeSearchResultsOnOutsideClick); - // try { - // const response = await backendFetch('/admin-about', { - // headers: { - // Accept: 'application/json', - // 'X-Requested-With': 'XMLHttpRequest' - // } - // }); - // - // if (response.ok) { - // adminData.value = await response.json(); - // } - // } catch (error) { - // console.error('Failed to fetch admin data:', error); - // } + try { + const response = await backendFetch('/admin-about', { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (response.ok) { + adminData.value = await response.json(); + } + } catch (error) { + console.error('Failed to fetch admin data:', error); + } }); onBeforeUnmount(() => { diff --git a/tests/Unit/assets/vue/layouts/AdminLayout.spec.js b/tests/Unit/assets/vue/layouts/AdminLayout.spec.js index af25148..469d347 100644 --- a/tests/Unit/assets/vue/layouts/AdminLayout.spec.js +++ b/tests/Unit/assets/vue/layouts/AdminLayout.spec.js @@ -96,6 +96,20 @@ describe('AdminLayout', () => { expect(wrapper.text()).toContain('Page Content') }) + it('loads admin data on mount', async () => { + const wrapper = createWrapper() + + await flushPromises() + + expect(backendFetch).toHaveBeenCalledWith( + '/admin-about', + expect.any(Object) + ) + + expect(wrapper.text()).toContain('admin') + expect(wrapper.text()).toContain('Super Admin') + }) + it('shows fallback admin name', () => { const wrapper = createWrapper() From 1820a6a615b63741e1dff308b372bb47c03d0009 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 23 Sep 2026 21:24:18 +0400 Subject: [PATCH 02/21] feat: add reusable BaseModal and ActionButton components, update modals for consistency and simplify BaseBadge styling --- assets/vue/components/base/ActionButton.vue | 47 ++++++ assets/vue/components/base/BaseBadge.vue | 8 +- assets/vue/components/base/BaseIcon.vue | 13 +- assets/vue/components/base/BaseModal.vue | 66 ++++++++ .../campaigns/CampaignDirectory.vue | 153 ++++++++---------- .../campaigns/ViewCampaignModal.vue | 26 +-- .../components/lists/AddSubscribersModal.vue | 70 ++++---- .../vue/components/lists/CreateListModal.vue | 66 +++----- assets/vue/components/lists/EditListModal.vue | 66 +++----- assets/vue/components/lists/ListDirectory.vue | 127 ++++----------- .../public-pages/PublicPagesDirectory.vue | 70 ++------ .../settings/CreateAdminAttributeModal.vue | 30 +--- .../components/settings/CreateAdminModal.vue | 66 +++----- .../CreateSubscriberAttributeModal.vue | 30 +--- .../settings/EditAdminAttributeModal.vue | 30 +--- .../components/settings/EditAdminModal.vue | 66 +++----- .../settings/EditSubscriberAttributeModal.vue | 30 +--- .../settings/SettingsAdminAttributes.vue | 67 ++------ .../components/settings/SettingsAdmins.vue | 57 ++----- .../settings/SettingsSubscriberAttributes.vue | 67 ++------ .../subscribers/SubscriberModal.vue | 68 ++++---- .../components/templates/TemplateLibrary.vue | 97 ++++------- assets/vue/layouts/AdminLayout.vue | 6 +- .../settings/EditAdminModal.spec.js | 2 +- 24 files changed, 529 insertions(+), 799 deletions(-) create mode 100644 assets/vue/components/base/ActionButton.vue create mode 100644 assets/vue/components/base/BaseModal.vue diff --git a/assets/vue/components/base/ActionButton.vue b/assets/vue/components/base/ActionButton.vue new file mode 100644 index 0000000..7c145a6 --- /dev/null +++ b/assets/vue/components/base/ActionButton.vue @@ -0,0 +1,47 @@ + + + + \ No newline at end of file diff --git a/assets/vue/components/base/BaseBadge.vue b/assets/vue/components/base/BaseBadge.vue index 53f2fec..4872242 100644 --- a/assets/vue/components/base/BaseBadge.vue +++ b/assets/vue/components/base/BaseBadge.vue @@ -11,7 +11,7 @@ import { computed } from 'vue' const props = defineProps({ variant: { type: String, - default: 'neutral', // neutral | counter + default: 'neutral', // neutral | counter | success | danger | info }, }) @@ -20,6 +20,12 @@ const badgeClass = computed(() => { switch (props.variant) { case 'counter': return `${base} bg-indigo-50 dark:bg-indigo-500/10 text-ext-wf3 dark:text-indigo-300 border border-indigo-100 dark:border-indigo-500/20`; + case 'success': + return `${base} bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400`; + case 'danger': + return `${base} bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400`; + case 'info': + return `${base} bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400`; case 'neutral': default: return `${base} bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-200`; diff --git a/assets/vue/components/base/BaseIcon.vue b/assets/vue/components/base/BaseIcon.vue index 573d2f8..dc11ff5 100644 --- a/assets/vue/components/base/BaseIcon.vue +++ b/assets/vue/components/base/BaseIcon.vue @@ -18,6 +18,13 @@ const props = defineProps({ type: Boolean, default: false, }, + + // when true, skips the default slate/indigo color so the icon inherits + // currentColor from its parent (e.g. white text on a solid button) + inheritColor: { + type: Boolean, + default: false, + }, }); // Simple internal SVG registry @@ -99,6 +106,8 @@ const icons = { moon: ``, start: ``, + + plus: ``, }; const svg = computed(() => icons[props.name] || ""); @@ -111,7 +120,9 @@ const wrapperClass = computed(() => { "transition-colors", ]; - if (props.active) { + if (props.inheritColor) { + // no color class: SVG's stroke="currentColor" picks up the parent's text color + } else if (props.active) { classes.push("text-ext-wf3 dark:text-indigo-400"); } else { classes.push("text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-300"); diff --git a/assets/vue/components/base/BaseModal.vue b/assets/vue/components/base/BaseModal.vue new file mode 100644 index 0000000..2de8cd7 --- /dev/null +++ b/assets/vue/components/base/BaseModal.vue @@ -0,0 +1,66 @@ + + + \ No newline at end of file diff --git a/assets/vue/components/campaigns/CampaignDirectory.vue b/assets/vue/components/campaigns/CampaignDirectory.vue index fc8a433..97e47a7 100644 --- a/assets/vue/components/campaigns/CampaignDirectory.vue +++ b/assets/vue/components/campaigns/CampaignDirectory.vue @@ -111,65 +111,57 @@
- - - - - - +

- - - - - - +

-

-
-
-

Campaign details: {{ campaign?.id || '-' }}

- -
-

Loading campaign... @@ -123,12 +111,12 @@ No campaign selected.

-
-
+ \ No newline at end of file diff --git a/assets/vue/components/settings/SettingsAdminAttributes.vue b/assets/vue/components/settings/SettingsAdminAttributes.vue index 78b2976..5db9b02 100644 --- a/assets/vue/components/settings/SettingsAdminAttributes.vue +++ b/assets/vue/components/settings/SettingsAdminAttributes.vue @@ -1,267 +1,32 @@ + \ No newline at end of file diff --git a/assets/vue/components/settings/SettingsSubscriberAttributes.vue b/assets/vue/components/settings/SettingsSubscriberAttributes.vue index 7393c15..24e969c 100644 --- a/assets/vue/components/settings/SettingsSubscriberAttributes.vue +++ b/assets/vue/components/settings/SettingsSubscriberAttributes.vue @@ -1,267 +1,32 @@ + \ No newline at end of file From e5d0525c3ce595a3bdb6e7bf02ba91858ae4a315 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 23 Sep 2026 22:02:40 +0400 Subject: [PATCH 04/21] feat: implement BaseDataTable component and refactor attribute, mailing list, public pages, and admin management views to use it --- assets/vue/components/base/BaseDataTable.vue | 79 ++++++ assets/vue/components/lists/ListDirectory.vue | 181 +++++-------- .../public-pages/PublicPagesDirectory.vue | 240 +++++++----------- .../settings/AttributeDirectory.vue | 203 +++++---------- .../components/settings/SettingsAdmins.vue | 158 +++++------- 5 files changed, 372 insertions(+), 489 deletions(-) create mode 100644 assets/vue/components/base/BaseDataTable.vue diff --git a/assets/vue/components/base/BaseDataTable.vue b/assets/vue/components/base/BaseDataTable.vue new file mode 100644 index 0000000..bbdc732 --- /dev/null +++ b/assets/vue/components/base/BaseDataTable.vue @@ -0,0 +1,79 @@ + + + + \ No newline at end of file diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue index 916adee..6a6cdf0 100644 --- a/assets/vue/components/lists/ListDirectory.vue +++ b/assets/vue/components/lists/ListDirectory.vue @@ -13,141 +13,89 @@ -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-

#{{ list.id }}

-

{{ list.name }}

-
- - - {{ isPublic(list) ? 'Public' : 'Private' }} - -
- -
- + + + + + + +
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-

#{{ page.id }}

-

{{ page.title || `Subscribe page #${page.id}` }}

-

Owner: {{ page.owner?.loginName || page.owner?.email || 'No owner' }}

-
-
- {{ isRowBusy(page.id) ? 'Updating...' : '' }} -
-
- -
- - - -
- -
- + + + + + + + @@ -178,6 +133,7 @@ import { useRouter } from 'vue-router' import { Requests } from '@tatevikgr/rest-api-client' import BaseIcon from '../base/BaseIcon.vue' import ActionButton from '../base/ActionButton.vue' +import BaseDataTable from '../base/BaseDataTable.vue' import { subscribePagesClient } from '../../api' const router = useRouter() diff --git a/assets/vue/components/settings/AttributeDirectory.vue b/assets/vue/components/settings/AttributeDirectory.vue index a048cc3..d26b564 100644 --- a/assets/vue/components/settings/AttributeDirectory.vue +++ b/assets/vue/components/settings/AttributeDirectory.vue @@ -22,150 +22,86 @@
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- -
-

- #{{ attribute.id }} -

- -

- {{ attribute.name }} -

- -

- {{ attribute.type === 'textline' ? 'Text' : attribute.type === 'hidden' ? 'Hidden' : attribute.type }} -

-
- - - {{ attribute.required ? 'Required' : 'Optional' }} - - -
- -
- - + + + + + + +
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-

#{{ admin.id }}

-

{{ admin.loginName }}

-

{{ admin.email }}

-
- - - {{ admin.superUser ? 'Super' : 'User' }} - -
- -

- Created: {{ formatDate(admin.createdAt) }} -

- -
- + + + + + + +
@@ -151,6 +112,7 @@ import { fetchAllAdmins, adminClient } from '../../api' import BaseIcon from "../base/BaseIcon.vue"; import BaseBadge from "../base/BaseBadge.vue"; import ActionButton from "../base/ActionButton.vue"; +import BaseDataTable from "../base/BaseDataTable.vue"; const admins = ref([]) const isLoading = ref(false) From 6dfac1a3513df5a367646a2cb23cae92c7813996 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 23 Sep 2026 22:43:34 +0400 Subject: [PATCH 05/21] feat: enhance BaseBadge component with additional variants and refactor usage across Bounce and Subscriber views --- assets/vue/components/base/BaseBadge.vue | 6 +- .../vue/components/bounces/BounceOverview.vue | 17 ++--- assets/vue/components/bounces/BouncePer.vue | 15 ++-- assets/vue/components/bounces/BounceRules.vue | 72 ++++++++----------- .../campaigns/CampaignDirectory.vue | 26 +++---- .../campaigns/StuckCampaignsList.vue | 9 +-- .../components/subscribers/ImportResult.vue | 31 +++----- .../subscribers/SubscriberModal.vue | 7 +- .../subscribers/SubscriberTable.vue | 36 +++------- assets/vue/views/ListSubscribersView.vue | 15 ++-- .../components/bounces/BounceOverview.spec.js | 6 +- .../vue/components/bounces/BouncePer.spec.js | 22 +++--- .../components/bounces/BounceRules.spec.js | 8 +-- .../campaigns/CampaignDirectory.spec.js | 4 +- 14 files changed, 111 insertions(+), 163 deletions(-) diff --git a/assets/vue/components/base/BaseBadge.vue b/assets/vue/components/base/BaseBadge.vue index 4872242..2ac0796 100644 --- a/assets/vue/components/base/BaseBadge.vue +++ b/assets/vue/components/base/BaseBadge.vue @@ -11,7 +11,7 @@ import { computed } from 'vue' const props = defineProps({ variant: { type: String, - default: 'neutral', // neutral | counter | success | danger | info + default: 'neutral', // neutral | counter | success | danger | info | warning | purple }, }) @@ -26,6 +26,10 @@ const badgeClass = computed(() => { return `${base} bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400`; case 'info': return `${base} bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400`; + case 'warning': + return `${base} bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400`; + case 'purple': + return `${base} bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400`; case 'neutral': default: return `${base} bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-200`; diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue index c368bf7..4bee75b 100644 --- a/assets/vue/components/bounces/BounceOverview.vue +++ b/assets/vue/components/bounces/BounceOverview.vue @@ -49,9 +49,9 @@ {{ bounce.email }} {{ bounce.subject }} - + {{ bounce.status }} - + {{ bounce.comment }} @@ -126,6 +126,7 @@ diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue index 6a6cdf0..9380275 100644 --- a/assets/vue/components/lists/ListDirectory.vue +++ b/assets/vue/components/lists/ListDirectory.vue @@ -3,14 +3,9 @@

Mailing Lists

- +
import { onMounted, ref } from 'vue' import { useRouter } from 'vue-router' -import BaseIcon from '../base/BaseIcon.vue' +import BaseButton from '../base/BaseButton.vue' import BaseBadge from '../base/BaseBadge.vue' import ActionButton from '../base/ActionButton.vue' import BaseDataTable from '../base/BaseDataTable.vue' diff --git a/assets/vue/components/public-pages/PublicPagesDirectory.vue b/assets/vue/components/public-pages/PublicPagesDirectory.vue index 9e4202f..d3b43cf 100644 --- a/assets/vue/components/public-pages/PublicPagesDirectory.vue +++ b/assets/vue/components/public-pages/PublicPagesDirectory.vue @@ -2,15 +2,9 @@

Subscribe Pages

- +
- +
import { onMounted, ref } from 'vue' -import BaseIcon from '../base/BaseIcon.vue' +import BaseButton from '../base/BaseButton.vue' import BaseBadge from '../base/BaseBadge.vue' import ActionButton from '../base/ActionButton.vue' import BaseDataTable from '../base/BaseDataTable.vue' diff --git a/assets/vue/components/settings/SettingsAdmins.vue b/assets/vue/components/settings/SettingsAdmins.vue index fb28640..54ec0c6 100644 --- a/assets/vue/components/settings/SettingsAdmins.vue +++ b/assets/vue/components/settings/SettingsAdmins.vue @@ -6,14 +6,9 @@

Manage administrator accounts and permissions

- +
- + @@ -72,14 +67,9 @@ blacklisted - +
@@ -102,7 +92,7 @@ diff --git a/tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js b/tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js index a1c3f2e..90859fc 100644 --- a/tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js +++ b/tests/Unit/assets/vue/components/dashboard/CampaignsTable.spec.js @@ -85,46 +85,46 @@ describe('row rendering', () => { }) }) -describe('statusClass', () => { +describe('status badge', () => { const mountWithStatus = (status) => mount(CampaignsTable, { props: { rows: [makeRow({ status })] } }) - it('applies green classes for "sent"', () => { + it('applies the success (emerald) variant for "sent"', () => { const wrapper = mountWithStatus('sent') - expect(wrapper.find('span.bg-green-100').exists()).toBe(true) - expect(wrapper.find('span.text-green-800').exists()).toBe(true) + expect(wrapper.find('span.bg-emerald-100').exists()).toBe(true) + expect(wrapper.find('span.text-emerald-700').exists()).toBe(true) }) - it('applies blue classes for "scheduled"', () => { + it('applies the info (blue) variant for "scheduled"', () => { const wrapper = mountWithStatus('scheduled') expect(wrapper.find('span.bg-blue-100').exists()).toBe(true) - expect(wrapper.find('span.text-blue-800').exists()).toBe(true) + expect(wrapper.find('span.text-blue-700').exists()).toBe(true) }) - it('applies gray classes for "draft"', () => { + it('applies the neutral (gray) variant for "draft"', () => { const wrapper = mountWithStatus('draft') expect(wrapper.find('span.bg-gray-100').exists()).toBe(true) expect(wrapper.find('span.text-gray-800').exists()).toBe(true) }) - it('applies gray fallback classes for an unknown status', () => { + it('applies the neutral (gray) fallback variant for an unknown status', () => { const wrapper = mountWithStatus('paused') expect(wrapper.find('span.bg-gray-100').exists()).toBe(true) expect(wrapper.find('span.text-gray-800').exists()).toBe(true) }) - it('is case-insensitive — "Sent" resolves to green', () => { + it('is case-insensitive — "Sent" resolves to the success variant', () => { const wrapper = mountWithStatus('Sent') - expect(wrapper.find('span.bg-green-100').exists()).toBe(true) + expect(wrapper.find('span.bg-emerald-100').exists()).toBe(true) }) - it('is case-insensitive — "SCHEDULED" resolves to blue', () => { + it('is case-insensitive — "SCHEDULED" resolves to the info variant', () => { const wrapper = mountWithStatus('SCHEDULED') expect(wrapper.find('span.bg-blue-100').exists()).toBe(true) }) it('renders the status label inside the badge', () => { const wrapper = mountWithStatus('sent') - expect(wrapper.find('span.bg-green-100').text()).toBe('sent') + expect(wrapper.find('span.bg-emerald-100').text()).toBe('sent') }) }) From 7a2b4f1163d32b01f344e885376c37ba3499b998 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 24 Sep 2026 17:51:45 +0400 Subject: [PATCH 07/21] feat: implement campaign pagination and statistics composables for campaign management --- .../campaigns/CampaignDirectory.vue | 412 +++--------------- assets/vue/composables/useCampaignLists.js | 64 +++ .../vue/composables/useCampaignPagination.js | 223 ++++++++++ .../vue/composables/useCampaignStatistics.js | 100 +++++ 4 files changed, 439 insertions(+), 360 deletions(-) create mode 100644 assets/vue/composables/useCampaignLists.js create mode 100644 assets/vue/composables/useCampaignPagination.js create mode 100644 assets/vue/composables/useCampaignStatistics.js diff --git a/assets/vue/components/campaigns/CampaignDirectory.vue b/assets/vue/components/campaigns/CampaignDirectory.vue index 4a0972d..cddaf3e 100644 --- a/assets/vue/components/campaigns/CampaignDirectory.vue +++ b/assets/vue/components/campaigns/CampaignDirectory.vue @@ -311,64 +311,74 @@ import ViewCampaignModal from "./ViewCampaignModal.vue"; import ActionButton from '../base/ActionButton.vue' import BaseBadge from '../base/BaseBadge.vue' import BaseDataTable from '../base/BaseDataTable.vue' +import { useCampaignPagination } from '../../composables/useCampaignPagination' +import { useCampaignStatistics } from '../../composables/useCampaignStatistics' +import { useCampaignLists } from '../../composables/useCampaignLists' const pageSize = 5 const route = useRoute() const router = useRouter() -const rawCampaignsByPage = ref(new Map()) -const cursorsByPage = ref(new Map([[1, null]])) -const totalForFilter = ref(0) -const listsByCampaignId = ref({}) -const expandedListsByCampaignId = ref({}) -const statisticsByCampaignId = ref({}) -const loadingListsByCampaignId = ref({}) -const actionLoadingByCampaignId = ref({}) -const actionFeedbackByCampaignId = ref({}) -const isLoading = ref(false) -const errorMessage = ref('') -const mailingLists = ref([]) const allowedStatuses = ['all', 'sent', 'active', 'draft'] +const activeStatuses = new Set(['submitted', 'prepared', 'inprocess']) -const parseStatusQuery = (statusQuery) => { - const value = Array.isArray(statusQuery) ? statusQuery[0] : statusQuery - return allowedStatuses.includes(value) ? value : 'all' -} - -const parsePageQuery = (pageQuery) => { - const queryValue = Array.isArray(pageQuery) ? pageQuery[0] : pageQuery - const page = Number.parseInt(String(queryValue ?? ''), 10) - return Number.isNaN(page) || page < 1 ? 1 : page -} +// Maps a UI filter tab to the server-side `status` query param. 'active' groups several +// raw statuses, sent as a comma-separated list the backend matches via IN(...). +const statusParamForFilter = (filterId) => ({ + sent: 'sent', + draft: 'draft', + active: Array.from(activeStatuses).join(','), +}[filterId] ?? null) -const statusFilter = computed({ - get() { - return parseStatusQuery(route.query.status) - }, - async set(value) { - const normalized = allowedStatuses.includes(value) ? value : 'all' - const nextQuery = { ...route.query } +const { + statusFilter, + currentPage, + rawItems: rawCampaigns, + total: totalForFilter, + isLoading, + errorMessage, + canGoPrevious, + canGoNext, + rangeStart, + rangeEnd, + setFilter, + previousPage, + nextPage, + loadUpToPage, + refreshCurrentPage, +} = useCampaignPagination({ + route, + router, + pageSize, + allowedStatuses, + fetchPage: (afterId, limit, status) => + campaignClient.getCampaigns(afterId, limit, null, statusParamForFilter(status), 'desc'), +}) - if (normalized === 'all') { - delete nextQuery.status - } else { - nextQuery.status = normalized - } +const { + showStatistics, + getCampaignStatistics, + fetchCampaignStatistics, +} = useCampaignStatistics(statisticsClient) - delete nextQuery.page +const { + getCampaignLists, + isListsLoading, + isListsExpanded, + toggleListsExpanded, + fetchListsForVisibleCampaigns, +} = useCampaignLists(listMessagesClient) - await router.replace({ query: nextQuery }) - } -}) +const mailingLists = ref([]) +const actionLoadingByCampaignId = ref({}) +const actionFeedbackByCampaignId = ref({}) -const currentPage = ref(parsePageQuery(route.query.page)) const isViewModalOpen = ref(false) const isViewLoading = ref(false) const selectedCampaign = ref(null) const viewErrorMessage = ref('') const isResending = ref(false) const resendErrorMessage = ref('') -const showStatistics = ref(true) const filterOptions = [ { id: 'all', label: 'All' }, @@ -384,8 +394,6 @@ const statusVariants = { unknown: 'warning' } -const activeStatuses = new Set(['submitted', 'prepared', 'inprocess']) - const resolveStatusKey = (statusRaw) => { if (statusRaw === 'sent') return 'sent' if (statusRaw === 'draft') return 'draft' @@ -440,13 +448,6 @@ const formatDuration = (fromValue, toValue) => { return `${hours}h ${minutes}m` } -const getCampaignLists = (campaignId) => listsByCampaignId.value[campaignId] || [] -const getCampaignStatistics = (campaignId) => statisticsByCampaignId.value[campaignId] || { - bounces: 0, - sent: 0, - uniqueViews: 0, -} - const fetchMailingLists = async () => { try { mailingLists.value = await fetchAllLists() @@ -456,14 +457,6 @@ const fetchMailingLists = async () => { } } -const isListsLoading = (campaignId) => loadingListsByCampaignId.value[campaignId] === true -const isListsExpanded = (campaignId) => expandedListsByCampaignId.value[campaignId] === true -const toggleListsExpanded = (campaignId) => { - expandedListsByCampaignId.value = { - ...expandedListsByCampaignId.value, - [campaignId]: !isListsExpanded(campaignId) - } -} const isActionLoading = (campaignId) => actionLoadingByCampaignId.value[campaignId] === true const getActionFeedback = (campaignId) => actionFeedbackByCampaignId.value[campaignId] || null @@ -604,103 +597,6 @@ const handleCopyToDraft = async (campaignId) => { } } -// Maps a UI filter tab to the server-side `status` query param. 'active' groups several -// raw statuses, sent as a comma-separated list the backend matches via IN(...). -const statusParamForFilter = (filterId) => ({ - sent: 'sent', - draft: 'draft', - active: Array.from(activeStatuses).join(','), -}[filterId] ?? null) - -// Bumped whenever the filter resets pagination, so a slow response from a since-abandoned -// filter can never overwrite the current view after a newer request has already landed. -let paginationGeneration = 0 -const pageRequestsInFlight = new Map() - -const resetPagination = () => { - paginationGeneration += 1 - pageRequestsInFlight.clear() - rawCampaignsByPage.value = new Map() - cursorsByPage.value = new Map([[1, null]]) -} - -// Fetches and caches one page (5 campaigns) at a time, newest-first, filtered server-side by the -// active status tab - no more draining the entire campaign list into memory up front. This only -// fetches/caches; it never touches `currentPage` itself, so walking through intermediate pages -// (see loadUpToPage) can't leak a transient wrong page number out to the URL-syncing watchers. -const loadPage = (page) => { - if (rawCampaignsByPage.value.has(page)) { - return Promise.resolve() - } - - const inFlight = pageRequestsInFlight.get(page) - if (inFlight) return inFlight - - const generation = paginationGeneration - const request = (async () => { - isLoading.value = true - errorMessage.value = '' - - try { - const afterId = cursorsByPage.value.get(page) ?? null - const response = await campaignClient.getCampaigns( - afterId, - pageSize, - null, - statusParamForFilter(statusFilter.value), - 'desc' - ) - if (generation !== paginationGeneration) return - - const items = Array.isArray(response?.items) ? response.items : [] - - rawCampaignsByPage.value.set(page, items) - totalForFilter.value = Number(response?.pagination?.total ?? 0) - - if (response?.pagination?.hasMore) { - cursorsByPage.value.set(page + 1, response?.pagination?.nextCursor ?? null) - } - } catch (error) { - if (generation === paginationGeneration) { - console.error(`Failed to load campaigns page ${page}:`, error) - errorMessage.value = 'Failed to load campaigns.' - } - } finally { - isLoading.value = false - pageRequestsInFlight.delete(page) - } - })() - - pageRequestsInFlight.set(page, request) - return request -} - -// Navigates to a page, fetching/caching every page from 1 up to it along the way (cursors for -// unvisited pages aren't known ahead of time), then commits currentPage exactly once at the end. -const loadUpToPage = async (targetPage) => { - for (let page = 1; page <= targetPage; page += 1) { - await loadPage(page) - } - currentPage.value = targetPage -} - -// A mutation can shift which campaigns fall on the current page and every page after it -// (but never on earlier pages), so drop those from the cache and refetch just the current one. -const invalidateFromCurrentPage = () => { - const page = currentPage.value - for (const key of [...rawCampaignsByPage.value.keys()]) { - if (key >= page) rawCampaignsByPage.value.delete(key) - } - for (const key of [...cursorsByPage.value.keys()]) { - if (key > page) cursorsByPage.value.delete(key) - } -} - -const refreshCurrentPage = async () => { - invalidateFromCurrentPage() - await loadPage(currentPage.value) -} - // Expensive (date formatting, statistics/lists lookups) — run only for the page being rendered. const normalizeCampaign = (campaign) => { const statusRaw = (campaign?.messageMetadata?.status || '').toLowerCase() @@ -734,161 +630,7 @@ const normalizeCampaign = (campaign) => { } } -const paginatedCampaigns = computed(() => (rawCampaignsByPage.value.get(currentPage.value) ?? []).map(normalizeCampaign)) - -const totalPages = computed(() => Math.max(1, Math.ceil(totalForFilter.value / pageSize))) -const canGoPrevious = computed(() => currentPage.value > 1) -const canGoNext = computed(() => currentPage.value < totalPages.value) - -const rangeStart = computed(() => { - if (totalForFilter.value === 0) return 0 - return (currentPage.value - 1) * pageSize + 1 -}) - -const rangeEnd = computed(() => { - if (totalForFilter.value === 0) return 0 - return Math.min(currentPage.value * pageSize, totalForFilter.value) -}) - -const setFilter = (filterId) => { - statusFilter.value = filterId -} - -const previousPage = async () => { - if (canGoPrevious.value) { - const target = currentPage.value - 1 - await loadPage(target) - currentPage.value = target - } -} - -const nextPage = async () => { - if (canGoNext.value) { - const target = currentPage.value + 1 - await loadPage(target) - currentPage.value = target - } -} - -// maxPages is a safety net against runaway loops, not a functional cap - it's far above any -// realistic dataset size, and hitting it logs a warning instead of silently truncating results. -const drainPaginated = async (fetchPage, onItems, { limit = 100, maxPages = 500 } = {}) => { - let cursor = null - let pages = 0 - - while (pages < maxPages) { - const response = await fetchPage(cursor, limit) - const items = Array.isArray(response?.items) ? response.items : [] - onItems(items) - - const hasMore = Boolean(response?.pagination?.hasMore) - const nextCursor = response?.pagination?.nextCursor ?? null - // A cursor that doesn't advance (server pagination bug) would otherwise spin until maxPages, - // hammering the API with identical requests - bail out the moment it stops moving forward. - if (!hasMore || nextCursor === null || nextCursor === cursor) break - - cursor = nextCursor - pages += 1 - } - - if (pages >= maxPages) { - console.warn('Pagination guard reached; results may be incomplete.') - } -} - -const fetchCampaignStatistics = async () => { - try { - // Independent endpoints - written into separate maps so they can be fetched concurrently - // without one drain's pagination racing the other's, then merged once both are done. - const campaignStatsMap = {} - const viewOpensSentByCampaignId = {} - - await Promise.all([ - drainPaginated( - (cursor, limit) => statisticsClient.getCampaignStatistics(cursor, limit), - (items) => { - items.forEach((item) => { - campaignStatsMap[item.campaignId] = { - bounces: Number(item.bounces ?? 0), - sent: Number(item.sent ?? 0), - uniqueViews: Number(item.uniqueViews ?? 0), - } - }) - }, - { limit: 100 } - ), - drainPaginated( - (cursor, limit) => statisticsClient.getStatisticsOfViewOpens(cursor, limit), - (items) => { - items.forEach((item) => { - viewOpensSentByCampaignId[item.campaignId] = Number(item.sent ?? 0) - }) - }, - { limit: 100 } - ) - ]) - - const statisticsMap = { ...campaignStatsMap } - Object.entries(viewOpensSentByCampaignId).forEach(([campaignId, sent]) => { - const existing = statisticsMap[campaignId] ?? { bounces: 0, sent: 0, uniqueViews: 0 } - statisticsMap[campaignId] = { ...existing, sent } - }) - - statisticsByCampaignId.value = statisticsMap - showStatistics.value = true - } catch (error) { - if ( - error?.name === 'AuthorizationException' || - error?.code === 'AuthorizationException' || - error?.status === 403 - ) { - showStatistics.value = false - statisticsByCampaignId.value = {} - return - } - - throw error - } -} - -const fetchListsForVisibleCampaigns = async () => { - const pending = paginatedCampaigns.value - .map((campaign) => campaign.id) - .filter((campaignId) => !(campaignId in listsByCampaignId.value) && !loadingListsByCampaignId.value[campaignId]) - - if (pending.length === 0) return - - pending.forEach((campaignId) => { - loadingListsByCampaignId.value = { ...loadingListsByCampaignId.value, [campaignId]: true } - }) - - const results = await Promise.allSettled( - pending.map(async (campaignId) => { - const response = await listMessagesClient.getListsByMessage(campaignId) - return { - campaignId, - lists: Array.isArray(response?.items) ? response.items : [] - } - }) - ) - - const nextLists = { ...listsByCampaignId.value } - const nextLoading = { ...loadingListsByCampaignId.value } - - results.forEach((result, index) => { - const campaignId = pending[index] - if (result.status === 'fulfilled') { - nextLists[campaignId] = result.value.lists - } else { - nextLists[campaignId] = [] - console.error(`Failed to load lists for campaign ${campaignId}:`, result.reason) - } - nextLoading[campaignId] = false - }) - - listsByCampaignId.value = nextLists - loadingListsByCampaignId.value = nextLoading -} +const paginatedCampaigns = computed(() => rawCampaigns.value.map(normalizeCampaign)) onMounted(() => { loadUpToPage(currentPage.value) @@ -896,57 +638,7 @@ onMounted(() => { fetchMailingLists() }) -watch(statusFilter, () => { - resetPagination() - loadPage(1).then(() => { - currentPage.value = 1 - }) -}) - -watch(totalPages, (pages) => { - if (isLoading.value) return - if (currentPage.value > pages) { - loadPage(pages).then(() => { - currentPage.value = pages - }) - } -}) - -watch(() => route.query.page, (pageQuery) => { - const nextPage = parsePageQuery(pageQuery) - if (nextPage !== currentPage.value) { - loadUpToPage(nextPage) - } -}) - -watch(currentPage, async (page) => { - const normalizedPage = isLoading.value - ? Math.max(1, page) - : Math.min(Math.max(1, page), totalPages.value) - if (normalizedPage !== page) { - currentPage.value = normalizedPage - return - } - - const currentQueryPage = Array.isArray(route.query.page) ? route.query.page[0] : route.query.page - const desiredQueryPage = normalizedPage > 1 ? String(normalizedPage) : undefined - if (currentQueryPage === desiredQueryPage) { - return - } - - const nextQuery = { ...route.query } - if (desiredQueryPage !== undefined) { - nextQuery.page = desiredQueryPage - } else { - delete nextQuery.page - } - - await router.replace({ - query: nextQuery - }) -}) - watch(paginatedCampaigns, () => { - fetchListsForVisibleCampaigns() + fetchListsForVisibleCampaigns(paginatedCampaigns.value.map((campaign) => campaign.id)) }, { immediate: true }) diff --git a/assets/vue/composables/useCampaignLists.js b/assets/vue/composables/useCampaignLists.js new file mode 100644 index 0000000..fb18353 --- /dev/null +++ b/assets/vue/composables/useCampaignLists.js @@ -0,0 +1,64 @@ +import { ref } from 'vue' + +export function useCampaignLists(listMessagesClient) { + const listsByCampaignId = ref({}) + const loadingListsByCampaignId = ref({}) + const expandedListsByCampaignId = ref({}) + + const getCampaignLists = (campaignId) => listsByCampaignId.value[campaignId] || [] + const isListsLoading = (campaignId) => loadingListsByCampaignId.value[campaignId] === true + const isListsExpanded = (campaignId) => expandedListsByCampaignId.value[campaignId] === true + + const toggleListsExpanded = (campaignId) => { + expandedListsByCampaignId.value = { + ...expandedListsByCampaignId.value, + [campaignId]: !isListsExpanded(campaignId) + } + } + + const fetchListsForVisibleCampaigns = async (campaignIds) => { + const pending = campaignIds + .filter((campaignId) => !(campaignId in listsByCampaignId.value) && !loadingListsByCampaignId.value[campaignId]) + + if (pending.length === 0) return + + pending.forEach((campaignId) => { + loadingListsByCampaignId.value = { ...loadingListsByCampaignId.value, [campaignId]: true } + }) + + const results = await Promise.allSettled( + pending.map(async (campaignId) => { + const response = await listMessagesClient.getListsByMessage(campaignId) + return { + campaignId, + lists: Array.isArray(response?.items) ? response.items : [] + } + }) + ) + + const nextLists = { ...listsByCampaignId.value } + const nextLoading = { ...loadingListsByCampaignId.value } + + results.forEach((result, index) => { + const campaignId = pending[index] + if (result.status === 'fulfilled') { + nextLists[campaignId] = result.value.lists + } else { + nextLists[campaignId] = [] + console.error(`Failed to load lists for campaign ${campaignId}:`, result.reason) + } + nextLoading[campaignId] = false + }) + + listsByCampaignId.value = nextLists + loadingListsByCampaignId.value = nextLoading + } + + return { + getCampaignLists, + isListsLoading, + isListsExpanded, + toggleListsExpanded, + fetchListsForVisibleCampaigns, + } +} \ No newline at end of file diff --git a/assets/vue/composables/useCampaignPagination.js b/assets/vue/composables/useCampaignPagination.js new file mode 100644 index 0000000..616b420 --- /dev/null +++ b/assets/vue/composables/useCampaignPagination.js @@ -0,0 +1,223 @@ +import { computed, ref, watch } from 'vue' + +const parseStatusQuery = (statusQuery, allowedStatuses) => { + const value = Array.isArray(statusQuery) ? statusQuery[0] : statusQuery + return allowedStatuses.includes(value) ? value : 'all' +} + +const parsePageQuery = (pageQuery) => { + const queryValue = Array.isArray(pageQuery) ? pageQuery[0] : pageQuery + const page = Number.parseInt(String(queryValue ?? ''), 10) + return Number.isNaN(page) || page < 1 ? 1 : page +} + +// Cursor-based pagination synced with the route's `status`/`page` query params. Encapsulates the +// per-page cache, cursor bookkeeping, and the generation/in-flight-request guards needed so a slow +// response from a since-abandoned filter can never overwrite the current view. +export function useCampaignPagination({ route, router, pageSize, allowedStatuses, fetchPage }) { + const rawItemsByPage = ref(new Map()) + const cursorsByPage = ref(new Map([[1, null]])) + const total = ref(0) + const isLoading = ref(false) + const errorMessage = ref('') + + const statusFilter = computed({ + get() { + return parseStatusQuery(route.query.status, allowedStatuses) + }, + async set(value) { + const normalized = allowedStatuses.includes(value) ? value : 'all' + const nextQuery = { ...route.query } + + if (normalized === 'all') { + delete nextQuery.status + } else { + nextQuery.status = normalized + } + + delete nextQuery.page + + await router.replace({ query: nextQuery }) + } + }) + + const currentPage = ref(parsePageQuery(route.query.page)) + + let generation = 0 + const requestsInFlight = new Map() + + const resetPagination = () => { + generation += 1 + requestsInFlight.clear() + rawItemsByPage.value = new Map() + cursorsByPage.value = new Map([[1, null]]) + } + + // Fetches and caches one page at a time. This only fetches/caches; it never touches + // `currentPage` itself, so walking through intermediate pages (see loadUpToPage) can't leak a + // transient wrong page number out to the URL-syncing watchers. + const loadPage = (page) => { + if (rawItemsByPage.value.has(page)) { + return Promise.resolve() + } + + const inFlight = requestsInFlight.get(page) + if (inFlight) return inFlight + + const thisGeneration = generation + const request = (async () => { + isLoading.value = true + errorMessage.value = '' + + try { + const afterId = cursorsByPage.value.get(page) ?? null + const response = await fetchPage(afterId, pageSize, statusFilter.value) + if (thisGeneration !== generation) return + + const items = Array.isArray(response?.items) ? response.items : [] + + rawItemsByPage.value.set(page, items) + total.value = Number(response?.pagination?.total ?? 0) + + if (response?.pagination?.hasMore) { + cursorsByPage.value.set(page + 1, response?.pagination?.nextCursor ?? null) + } + } catch (error) { + if (thisGeneration === generation) { + console.error(`Failed to load campaigns page ${page}:`, error) + errorMessage.value = 'Failed to load campaigns.' + } + } finally { + isLoading.value = false + requestsInFlight.delete(page) + } + })() + + requestsInFlight.set(page, request) + return request + } + + // Navigates to a page, fetching/caching every page from 1 up to it along the way (cursors for + // unvisited pages aren't known ahead of time), then commits currentPage exactly once at the end. + const loadUpToPage = async (targetPage) => { + for (let page = 1; page <= targetPage; page += 1) { + await loadPage(page) + } + currentPage.value = targetPage + } + + // A mutation can shift which items fall on the current page and every page after it (but never + // on earlier pages), so drop those from the cache and refetch just the current one. + const invalidateFromCurrentPage = () => { + const page = currentPage.value + for (const key of [...rawItemsByPage.value.keys()]) { + if (key >= page) rawItemsByPage.value.delete(key) + } + for (const key of [...cursorsByPage.value.keys()]) { + if (key > page) cursorsByPage.value.delete(key) + } + } + + const refreshCurrentPage = async () => { + invalidateFromCurrentPage() + await loadPage(currentPage.value) + } + + const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) + const canGoPrevious = computed(() => currentPage.value > 1) + const canGoNext = computed(() => currentPage.value < totalPages.value) + + const rangeStart = computed(() => (total.value === 0 ? 0 : (currentPage.value - 1) * pageSize + 1)) + const rangeEnd = computed(() => (total.value === 0 ? 0 : Math.min(currentPage.value * pageSize, total.value))) + + const setFilter = (filterId) => { + statusFilter.value = filterId + } + + const previousPage = async () => { + if (canGoPrevious.value) { + const target = currentPage.value - 1 + await loadPage(target) + currentPage.value = target + } + } + + const nextPage = async () => { + if (canGoNext.value) { + const target = currentPage.value + 1 + await loadPage(target) + currentPage.value = target + } + } + + watch(statusFilter, () => { + resetPagination() + loadPage(1).then(() => { + currentPage.value = 1 + }) + }) + + watch(totalPages, (pages) => { + if (isLoading.value) return + if (currentPage.value > pages) { + loadPage(pages).then(() => { + currentPage.value = pages + }) + } + }) + + watch(() => route.query.page, (pageQuery) => { + const nextPage = parsePageQuery(pageQuery) + if (nextPage !== currentPage.value) { + loadUpToPage(nextPage) + } + }) + + watch(currentPage, async (page) => { + const normalizedPage = isLoading.value + ? Math.max(1, page) + : Math.min(Math.max(1, page), totalPages.value) + if (normalizedPage !== page) { + currentPage.value = normalizedPage + return + } + + const currentQueryPage = Array.isArray(route.query.page) ? route.query.page[0] : route.query.page + const desiredQueryPage = normalizedPage > 1 ? String(normalizedPage) : undefined + if (currentQueryPage === desiredQueryPage) { + return + } + + const nextQuery = { ...route.query } + if (desiredQueryPage !== undefined) { + nextQuery.page = desiredQueryPage + } else { + delete nextQuery.page + } + + await router.replace({ + query: nextQuery + }) + }) + + const rawItems = computed(() => rawItemsByPage.value.get(currentPage.value) ?? []) + + return { + statusFilter, + currentPage, + rawItems, + total, + isLoading, + errorMessage, + totalPages, + canGoPrevious, + canGoNext, + rangeStart, + rangeEnd, + setFilter, + previousPage, + nextPage, + loadUpToPage, + refreshCurrentPage, + } +} \ No newline at end of file diff --git a/assets/vue/composables/useCampaignStatistics.js b/assets/vue/composables/useCampaignStatistics.js new file mode 100644 index 0000000..b477ac9 --- /dev/null +++ b/assets/vue/composables/useCampaignStatistics.js @@ -0,0 +1,100 @@ +import { ref } from 'vue' + +// maxPages is a safety net against runaway loops, not a functional cap - it's far above any +// realistic dataset size, and hitting it logs a warning instead of silently truncating results. +const drainPaginated = async (fetchPage, onItems, { limit = 100, maxPages = 500 } = {}) => { + let cursor = null + let pages = 0 + + while (pages < maxPages) { + const response = await fetchPage(cursor, limit) + const items = Array.isArray(response?.items) ? response.items : [] + onItems(items) + + const hasMore = Boolean(response?.pagination?.hasMore) + const nextCursor = response?.pagination?.nextCursor ?? null + // A cursor that doesn't advance (server pagination bug) would otherwise spin until maxPages, + // hammering the API with identical requests - bail out the moment it stops moving forward. + if (!hasMore || nextCursor === null || nextCursor === cursor) break + + cursor = nextCursor + pages += 1 + } + + if (pages >= maxPages) { + console.warn('Pagination guard reached; results may be incomplete.') + } +} + +export function useCampaignStatistics(statisticsClient) { + const statisticsByCampaignId = ref({}) + const showStatistics = ref(true) + + const getCampaignStatistics = (campaignId) => statisticsByCampaignId.value[campaignId] || { + bounces: 0, + sent: 0, + uniqueViews: 0, + } + + const fetchCampaignStatistics = async () => { + try { + // Independent endpoints - written into separate maps so they can be fetched concurrently + // without one drain's pagination racing the other's, then merged once both are done. + const campaignStatsMap = {} + const viewOpensSentByCampaignId = {} + + await Promise.all([ + drainPaginated( + (cursor, limit) => statisticsClient.getCampaignStatistics(cursor, limit), + (items) => { + items.forEach((item) => { + campaignStatsMap[item.campaignId] = { + bounces: Number(item.bounces ?? 0), + sent: Number(item.sent ?? 0), + uniqueViews: Number(item.uniqueViews ?? 0), + } + }) + }, + { limit: 100 } + ), + drainPaginated( + (cursor, limit) => statisticsClient.getStatisticsOfViewOpens(cursor, limit), + (items) => { + items.forEach((item) => { + viewOpensSentByCampaignId[item.campaignId] = Number(item.sent ?? 0) + }) + }, + { limit: 100 } + ) + ]) + + const statisticsMap = { ...campaignStatsMap } + Object.entries(viewOpensSentByCampaignId).forEach(([campaignId, sent]) => { + const existing = statisticsMap[campaignId] ?? { bounces: 0, sent: 0, uniqueViews: 0 } + statisticsMap[campaignId] = { ...existing, sent } + }) + + statisticsByCampaignId.value = statisticsMap + showStatistics.value = true + } catch (error) { + if ( + error?.name === 'AuthorizationException' || + error?.code === 'AuthorizationException' || + error?.status === 403 + ) { + showStatistics.value = false + statisticsByCampaignId.value = {} + return + } + + throw error + } + } + + return { + statisticsByCampaignId, + showStatistics, + getCampaignStatistics, + fetchCampaignStatistics, + } +} \ No newline at end of file From a6a8cfeff2d80af102e7bcd10ff5b040d9d547fa Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 24 Sep 2026 18:06:56 +0400 Subject: [PATCH 08/21] chore: add .claude/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4f64876..caa8269 100755 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ /node_modules /drivers/ .codex +.claude/ From 8c02fe043c5f30143b76583030a83335e7d71cba Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 28 Sep 2026 10:06:26 +0400 Subject: [PATCH 09/21] feat: enhance authentication error handling and remove deprecated AuthGateSubscriber --- src/Controller/AuthController.php | 4 +- src/EventSubscriber/AuthGateSubscriber.php | 113 ----------------- .../AuthGateSubscriberTest.php | 115 ------------------ 3 files changed, 2 insertions(+), 230 deletions(-) delete mode 100644 src/EventSubscriber/AuthGateSubscriber.php delete mode 100644 tests/Unit/EventSubscriber/AuthGateSubscriberTest.php diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 00182f5..9c88586 100755 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -62,10 +62,10 @@ public function login(Request $request): Response $request->getSession()->save(); return $this->redirectAfterLogin($redirectTarget); - } catch (Exception $e) { - $error = $e->getCode() === 401 ? 'Invalid credentials: ' . $e->getMessage() : $e->getMessage(); } catch (GuzzleException $e) { $error = 'Invalid credentials or server error: ' . $e->getMessage(); + } catch (Exception $e) { + $error = $e->getCode() === 401 ? 'Invalid credentials: ' . $e->getMessage() : $e->getMessage(); } } diff --git a/src/EventSubscriber/AuthGateSubscriber.php b/src/EventSubscriber/AuthGateSubscriber.php deleted file mode 100644 index eb77fef..0000000 --- a/src/EventSubscriber/AuthGateSubscriber.php +++ /dev/null @@ -1,113 +0,0 @@ - ['onKernelRequest', 8], - ]; - } - - public function onKernelRequest(RequestEvent $event): void - { - if (!$event->isMainRequest()) { - return; - } - - $request = $event->getRequest(); - if ($this->isPublicPath($request)) { - return; - } - - $session = $request->getSession(); - if (!$session->has('auth_token')) { - $loginUrl = $this->buildLoginUrl($request); - $event->setResponse(new RedirectResponse($loginUrl)); - } - } - - private function isPublicPath(Request $request): bool - { - $path = $this->normalizePath($request->getPathInfo()); - - // Public login route - if ($path === '/login' || str_starts_with($path, '/login')) { - return true; - } - - // Allow Symfony debug/profiler and WDT if present - if (str_starts_with($path, '/_profiler') || str_starts_with($path, '/_wdt')) { - return true; - } - - // Allow static assets commonly served under these prefixes - foreach (self::ALLOW_LIST as $prefix) { - if ($this->matchesPrefix($path, $prefix)) { - return true; - } - } - - return false; - } - - private function buildLoginUrl(Request $request): string - { - $loginUrl = $this->urlGenerator->generate('login'); - $redirectTarget = $request->getRequestUri(); - - if (!$this->isSafeRedirectTarget($redirectTarget)) { - return $loginUrl; - } - - return $loginUrl . '?' . http_build_query(['redirect' => $redirectTarget]); - } - - private function matchesPrefix(string $path, string $prefix): bool - { - if (str_ends_with($prefix, '/')) { - $exactPath = rtrim($prefix, '/'); - return $path === $exactPath || str_starts_with($path, $prefix); - } - - return str_starts_with($path, $prefix); - } -} diff --git a/tests/Unit/EventSubscriber/AuthGateSubscriberTest.php b/tests/Unit/EventSubscriber/AuthGateSubscriberTest.php deleted file mode 100644 index 6096a2c..0000000 --- a/tests/Unit/EventSubscriber/AuthGateSubscriberTest.php +++ /dev/null @@ -1,115 +0,0 @@ -urlGenerator = $this->createMock(UrlGeneratorInterface::class); - $this->subscriber = new AuthGateSubscriber($this->urlGenerator); - } - - public function testRedirectsAnonymousUserToLoginWithOriginalPath(): void - { - $this->urlGenerator->method('generate')->with('login')->willReturn('/login'); - - $session = $this->createMock(SessionInterface::class); - $session->method('has')->with('auth_token')->willReturn(false); - - $request = Request::create('/lists/12/subscribers?page=2'); - $request->setSession($session); - - $event = new RequestEvent( - $this->createMock(HttpKernelInterface::class), - $request, - HttpKernelInterface::MAIN_REQUEST - ); - - $this->subscriber->onKernelRequest($event); - - $response = $event->getResponse(); - $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertSame('/login?redirect=%2Flists%2F12%2Fsubscribers%3Fpage%3D2', $response->getTargetUrl()); - } - - public function testSkipsRedirectForLoginPage(): void - { - $session = $this->createMock(SessionInterface::class); - - $request = Request::create('/login'); - $request->setSession($session); - - $event = new RequestEvent( - $this->createMock(HttpKernelInterface::class), - $request, - HttpKernelInterface::MAIN_REQUEST - ); - - $this->subscriber->onKernelRequest($event); - - $this->assertNull($event->getResponse()); - } - - public function testSkipsRedirectForPublicSubscribePage(): void - { - $session = $this->createMock(SessionInterface::class); - - $request = Request::create('/subscribe/7'); - $request->setSession($session); - - $event = new RequestEvent( - $this->createMock(HttpKernelInterface::class), - $request, - HttpKernelInterface::MAIN_REQUEST - ); - - $this->subscriber->onKernelRequest($event); - - $this->assertNull($event->getResponse()); - } - - public function testSkipsRedirectForPublicUnsubscribePaths(): void - { - $session = $this->createMock(SessionInterface::class); - - $request = Request::create('/unsubscribe'); - $request->setSession($session); - - $event = new RequestEvent( - $this->createMock(HttpKernelInterface::class), - $request, - HttpKernelInterface::MAIN_REQUEST - ); - - $this->subscriber->onKernelRequest($event); - $this->assertNull($event->getResponse()); - - $requestWithId = Request::create('/unsubscribe/7'); - $requestWithId->setSession($session); - - $eventWithId = new RequestEvent( - $this->createMock(HttpKernelInterface::class), - $requestWithId, - HttpKernelInterface::MAIN_REQUEST - ); - - $this->subscriber->onKernelRequest($eventWithId); - $this->assertNull($eventWithId->getResponse()); - } -} From f3a005e3c7bd11f1c1545254f47375e63339a6bf Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 28 Sep 2026 10:15:56 +0400 Subject: [PATCH 10/21] feat: refactor session handling by introducing traits for login URL building and session expired responses --- .../UnauthorizedSubscriber.php | 21 +++++------------- src/Security/SessionAuthenticator.php | 22 +++++-------------- src/Trait/LoginUrlBuilderTrait.php | 19 ++++++++++++++++ src/Trait/SessionExpiredResponseTrait.php | 19 ++++++++++++++++ 4 files changed, 48 insertions(+), 33 deletions(-) create mode 100644 src/Trait/LoginUrlBuilderTrait.php create mode 100644 src/Trait/SessionExpiredResponseTrait.php diff --git a/src/EventSubscriber/UnauthorizedSubscriber.php b/src/EventSubscriber/UnauthorizedSubscriber.php index d80c8cc..3daae44 100755 --- a/src/EventSubscriber/UnauthorizedSubscriber.php +++ b/src/EventSubscriber/UnauthorizedSubscriber.php @@ -4,7 +4,9 @@ namespace PhpList\WebFrontend\EventSubscriber; +use PhpList\WebFrontend\Trait\LoginUrlBuilderTrait; use PhpList\WebFrontend\Trait\RedirectValidationTrait; +use PhpList\WebFrontend\Trait\SessionExpiredResponseTrait; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -17,7 +19,9 @@ class UnauthorizedSubscriber implements EventSubscriberInterface { + use LoginUrlBuilderTrait; use RedirectValidationTrait; + use SessionExpiredResponseTrait; public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -65,11 +69,7 @@ public function onKernelException(ExceptionEvent $event): void $loginUrl = $this->buildLoginUrl($request->getRequestUri()); if ($request->isXmlHttpRequest()) { - $event->setResponse(new JsonResponse([ - 'error' => 'session_expired', - 'message' => 'Your session has expired. Please log in again.', - 'redirect' => $loginUrl, - ], 401)); + $event->setResponse($this->sessionExpiredJsonResponse($loginUrl)); return; } @@ -85,15 +85,4 @@ public function onKernelException(ExceptionEvent $event): void $event->setResponse(new RedirectResponse($loginUrl)); } } - - private function buildLoginUrl(string $redirectTarget): string - { - $loginUrl = $this->urlGenerator->generate('login'); - - if (!$this->isSafeRedirectTarget($redirectTarget)) { - return $loginUrl; - } - - return $loginUrl . '?' . http_build_query(['redirect' => $redirectTarget]); - } } diff --git a/src/Security/SessionAuthenticator.php b/src/Security/SessionAuthenticator.php index 212c0ef..882e5cf 100644 --- a/src/Security/SessionAuthenticator.php +++ b/src/Security/SessionAuthenticator.php @@ -4,8 +4,9 @@ namespace PhpList\WebFrontend\Security; +use PhpList\WebFrontend\Trait\LoginUrlBuilderTrait; use PhpList\WebFrontend\Trait\RedirectValidationTrait; -use Symfony\Component\HttpFoundation\JsonResponse; +use PhpList\WebFrontend\Trait\SessionExpiredResponseTrait; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,7 +22,9 @@ class SessionAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface { + use LoginUrlBuilderTrait; use RedirectValidationTrait; + use SessionExpiredResponseTrait; private const NOT_SUPPORTED_PATHS = [ '/login', @@ -94,27 +97,12 @@ public function start(Request $request, AuthenticationException $authException = $loginUrl = $this->buildLoginUrl($request->getRequestUri()); if ($request->isXmlHttpRequest()) { - return new JsonResponse([ - 'error' => 'session_expired', - 'message' => 'Your session has expired. Please log in again.', - 'redirect' => $loginUrl, - ], 401); + return $this->sessionExpiredJsonResponse($loginUrl); } return new RedirectResponse($loginUrl); } - private function buildLoginUrl(string $redirectTarget): string - { - $loginUrl = $this->urlGenerator->generate('login'); - - if (!$this->isSafeRedirectTarget($redirectTarget)) { - return $loginUrl; - } - - return $loginUrl . '?' . http_build_query(['redirect' => $redirectTarget]); - } - private function matchesPrefix(string $path, string $prefix): bool { if (str_ends_with($prefix, '/')) { diff --git a/src/Trait/LoginUrlBuilderTrait.php b/src/Trait/LoginUrlBuilderTrait.php new file mode 100644 index 0000000..1977888 --- /dev/null +++ b/src/Trait/LoginUrlBuilderTrait.php @@ -0,0 +1,19 @@ +urlGenerator->generate('login'); + + if (!$this->isSafeRedirectTarget($redirectTarget)) { + return $loginUrl; + } + + return $loginUrl . '?' . http_build_query(['redirect' => $redirectTarget]); + } +} diff --git a/src/Trait/SessionExpiredResponseTrait.php b/src/Trait/SessionExpiredResponseTrait.php new file mode 100644 index 0000000..3201360 --- /dev/null +++ b/src/Trait/SessionExpiredResponseTrait.php @@ -0,0 +1,19 @@ + 'session_expired', + 'message' => 'Your session has expired. Please log in again.', + 'redirect' => $loginUrl, + ], 401); + } +} From 0bc3bfd34fa96877cc23d37432ce485fadf2b307 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 28 Sep 2026 10:33:23 +0400 Subject: [PATCH 11/21] refactor: implement WantsJsonTrait for consistent JSON response handling --- src/Controller/BaseController.php | 2 +- src/Controller/EditorUploadController.php | 28 ++++++++----------- src/Controller/ListsController.php | 7 +++-- src/Controller/PublicSubscribeController.php | 2 +- src/Controller/SubscribersController.php | 7 +++-- src/Trait/WantsJsonTrait.php | 17 +++++++++++ .../Controller/EditorUploadControllerTest.php | 4 --- .../PublicSubscribeControllerTest.php | 2 +- 8 files changed, 40 insertions(+), 29 deletions(-) create mode 100644 src/Trait/WantsJsonTrait.php diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php index 142ad6f..f50e9b7 100644 --- a/src/Controller/BaseController.php +++ b/src/Controller/BaseController.php @@ -11,7 +11,7 @@ use PhpList\RestApiClient\Exception\AuthorizationException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -class BaseController extends AbstractController +abstract class BaseController extends AbstractController { public function __construct( protected AuthClient $authClient, diff --git a/src/Controller/EditorUploadController.php b/src/Controller/EditorUploadController.php index 743d9d7..dd14515 100644 --- a/src/Controller/EditorUploadController.php +++ b/src/Controller/EditorUploadController.php @@ -6,7 +6,6 @@ use PhpList\WebFrontend\Exception\UpstreamServiceException; use PhpList\WebFrontend\Service\EditorUploadService; -use RuntimeException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -26,21 +25,13 @@ public function upload(Request $request): JsonResponse $uploadedFile = $request->files->get('upload'); if (!$uploadedFile) { - return new JsonResponse([ - 'error' => [ - 'message' => 'No file was provided.', - ], - ], Response::HTTP_BAD_REQUEST); + return $this->jsonError('No file was provided.', Response::HTTP_BAD_REQUEST); } try { $result = $this->editorUploadService->storeImage($uploadedFile); } catch (UpstreamServiceException $exception) { - return new JsonResponse([ - 'error' => [ - 'message' => $exception->getMessage(), - ], - ], Response::HTTP_BAD_GATEWAY); + return $this->jsonError($exception->getMessage(), Response::HTTP_BAD_GATEWAY); } return new JsonResponse([ @@ -55,11 +46,7 @@ public function assets(): JsonResponse try { $assets = $this->editorUploadService->listAssets(); } catch (UpstreamServiceException $exception) { - return new JsonResponse([ - 'error' => [ - 'message' => $exception->getMessage(), - ], - ], Response::HTTP_BAD_GATEWAY); + return $this->jsonError($exception->getMessage(), Response::HTTP_BAD_GATEWAY); } return new JsonResponse([ @@ -69,4 +56,13 @@ public function assets(): JsonResponse ), ]); } + + private function jsonError(string $message, int $statusCode): JsonResponse + { + return new JsonResponse([ + 'error' => [ + 'message' => $message, + ], + ], $statusCode); + } } diff --git a/src/Controller/ListsController.php b/src/Controller/ListsController.php index 3dfb1ba..f895e05 100644 --- a/src/Controller/ListsController.php +++ b/src/Controller/ListsController.php @@ -5,6 +5,7 @@ namespace PhpList\WebFrontend\Controller; use PhpList\RestApiClient\Endpoint\ListClient; +use PhpList\WebFrontend\Trait\WantsJsonTrait; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -14,6 +15,8 @@ #[Route('/lists', name: 'list_')] class ListsController extends AbstractController { + use WantsJsonTrait; + public function __construct(private readonly ListClient $listClient) { } @@ -21,9 +24,7 @@ public function __construct(private readonly ListClient $listClient) #[Route('/', name: 'list', methods: ['GET'])] public function index(Request $request): JsonResponse|Response { - $accept = (string) $request->headers->get('Accept', ''); - $wantsJson = $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); - if (! $wantsJson) { + if (! $this->wantsJson($request)) { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Lists', ]); diff --git a/src/Controller/PublicSubscribeController.php b/src/Controller/PublicSubscribeController.php index 2bab727..a3d7b4f 100644 --- a/src/Controller/PublicSubscribeController.php +++ b/src/Controller/PublicSubscribeController.php @@ -24,8 +24,8 @@ class PublicSubscribeController extends BaseController { public function __construct( - private readonly SubscribePagesClient $subscribePagesClient, protected AuthClient $authClient, + private readonly SubscribePagesClient $subscribePagesClient, private readonly LanguageService $languageService, private readonly PublicSubscribeFormBuilder $formBuilder, private readonly PublicSubscribeFormValidator $formValidator, diff --git a/src/Controller/SubscribersController.php b/src/Controller/SubscribersController.php index 331da62..89d7a75 100644 --- a/src/Controller/SubscribersController.php +++ b/src/Controller/SubscribersController.php @@ -8,6 +8,7 @@ use PhpList\RestApiClient\Request\Subscriber\SubscribersFilterRequest; use PhpList\WebFrontend\Service\SubscriberCollectionNormalizer; use PhpList\WebFrontend\Service\SubscriberExportRequestFactory; +use PhpList\WebFrontend\Trait\WantsJsonTrait; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -18,6 +19,8 @@ #[Route('/subscribers', name: 'subscriber_')] class SubscribersController extends AbstractController { + use WantsJsonTrait; + public function __construct( private readonly SubscribersClient $subscribersClient, private readonly SubscriberCollectionNormalizer $subscriberCollectionNormalizer, @@ -32,9 +35,7 @@ public function __construct( #[Route('/', name: 'list', methods: ['GET'])] public function index(Request $request): JsonResponse|Response { - $accept = (string) $request->headers->get('Accept', ''); - $wantsJson = $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); - if (! $wantsJson) { + if (! $this->wantsJson($request)) { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Subscribers', ]); diff --git a/src/Trait/WantsJsonTrait.php b/src/Trait/WantsJsonTrait.php new file mode 100644 index 0000000..f3becbb --- /dev/null +++ b/src/Trait/WantsJsonTrait.php @@ -0,0 +1,17 @@ +headers->get('Accept', ''); + + return $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); + } +} diff --git a/tests/Integration/Controller/EditorUploadControllerTest.php b/tests/Integration/Controller/EditorUploadControllerTest.php index 33d3d3b..4561972 100644 --- a/tests/Integration/Controller/EditorUploadControllerTest.php +++ b/tests/Integration/Controller/EditorUploadControllerTest.php @@ -64,7 +64,6 @@ public function testUploadReturnsBadRequestWhenNoFileProvided(): void $uploadsClient->expects(self::never())->method('upload'); $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); - $response = $controller->upload(Request::create('/editor/upload', 'POST')); self::assertSame(400, $response->getStatusCode()); @@ -79,7 +78,6 @@ public function testUploadReturnsBadGatewayWhenApiFails(): void ->willThrowException(new ApiException('boom', 500)); $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); - $response = $controller->upload($this->createUploadRequest()); self::assertSame(502, $response->getStatusCode()); @@ -106,7 +104,6 @@ public function testAssetsReturnsMappedItems(): void ]); $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); - $response = $controller->assets(); self::assertSame(200, $response->getStatusCode()); @@ -123,7 +120,6 @@ public function testAssetsReturnsBadGatewayWhenApiFails(): void ->willThrowException(new ApiException('unavailable', 500)); $controller = new EditorUploadController(new EditorUploadService($uploadsClient)); - $response = $controller->assets(); self::assertSame(502, $response->getStatusCode()); diff --git a/tests/Integration/Controller/PublicSubscribeControllerTest.php b/tests/Integration/Controller/PublicSubscribeControllerTest.php index 65b717e..546c8eb 100644 --- a/tests/Integration/Controller/PublicSubscribeControllerTest.php +++ b/tests/Integration/Controller/PublicSubscribeControllerTest.php @@ -61,8 +61,8 @@ public function testSubscribePageRendersForAnonymousUserWhenAdminLookupApiFails( ]); $controller = new PublicSubscribeController( - $subscribePagesClient, $authClient, + $subscribePagesClient, new LanguageService(), $formBuilder, $this->createMock(PublicSubscribeFormValidator::class), From e51f446f56e6b08d57bb744cd275591187adc5eb Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 28 Sep 2026 11:00:30 +0400 Subject: [PATCH 12/21] feat: introduce useApiValidationErrors composable for improved validation error handling --- .../vue/composables/useApiValidationErrors.js | 36 +++++++++++++++++++ assets/vue/views/CampaignEditView.vue | 33 ++--------------- assets/vue/views/TemplateEditView.vue | 31 ++-------------- 3 files changed, 40 insertions(+), 60 deletions(-) create mode 100644 assets/vue/composables/useApiValidationErrors.js diff --git a/assets/vue/composables/useApiValidationErrors.js b/assets/vue/composables/useApiValidationErrors.js new file mode 100644 index 0000000..16b7ffd --- /dev/null +++ b/assets/vue/composables/useApiValidationErrors.js @@ -0,0 +1,36 @@ +export function useApiValidationErrors(fieldLabels = {}) { + const normalizeFieldName = (fieldPath = '') => { + if (fieldLabels[fieldPath]) return fieldLabels[fieldPath] + + const fallback = String(fieldPath) + .split('.') + .pop() + ?.replace(/\[\d+]/g, '') + ?.replace(/_/g, ' ') + ?.replace(/([a-z])([A-Z])/g, '$1 $2') + ?.trim() + + if (!fallback) return 'Field' + return fallback.charAt(0).toUpperCase() + fallback.slice(1) + } + + const formatValidationErrors = (error) => { + const responseData = error?.responseData + const messages = [] + + if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { + Object.entries(responseData).forEach(([field, rawMessage]) => { + if (!rawMessage) return + const text = Array.isArray(rawMessage) ? rawMessage.join(' ') : String(rawMessage) + messages.push(`${normalizeFieldName(field)}: ${text}`) + }) + } + + return [...new Set(messages)] + } + + return { + normalizeFieldName, + formatValidationErrors, + } +} \ No newline at end of file diff --git a/assets/vue/views/CampaignEditView.vue b/assets/vue/views/CampaignEditView.vue index fc56c21..7a67ad4 100644 --- a/assets/vue/views/CampaignEditView.vue +++ b/assets/vue/views/CampaignEditView.vue @@ -395,6 +395,7 @@ import {RouterLink, useRoute, useRouter} from 'vue-router' import AdminLayout from '../layouts/AdminLayout.vue' import CkEditorField from '../components/base/CkEditorField.vue' import {campaignClient, fetchAllLists, listMessagesClient, templateClient} from '../api' +import {useApiValidationErrors} from '../composables/useApiValidationErrors' const route = useRoute() const router = useRouter() @@ -498,37 +499,7 @@ const validationFieldLabels = { 'schedule.embargo': 'Embargo until' } -const normalizeFieldName = (fieldPath = '') => { - if (validationFieldLabels[fieldPath]) return validationFieldLabels[fieldPath] - - const fallback = String(fieldPath) - .split('.') - .pop() - ?.replace(/\[\d+]/g, '') - ?.replace(/_/g, ' ') - ?.replace(/([a-z])([A-Z])/g, '$1 $2') - ?.trim() - - if (!fallback) return 'Field' - return fallback.charAt(0).toUpperCase() + fallback.slice(1) -} - -const formatValidationErrors = (error) => { - const responseData = error?.responseData - const fromObject = [] - - if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { - Object.entries(responseData).forEach(([field, rawMessage]) => { - if (!rawMessage) return - const text = Array.isArray(rawMessage) ? rawMessage.join(' ') : String(rawMessage) - fromObject.push(`${normalizeFieldName(field)}: ${text}`) - }) - } - - if (fromObject.length > 0) return [...new Set(fromObject)] - console.log('Failed to format validation errors:', error) - return [] -} +const {formatValidationErrors} = useApiValidationErrors(validationFieldLabels) const normalizeListIds = (values) => values diff --git a/assets/vue/views/TemplateEditView.vue b/assets/vue/views/TemplateEditView.vue index d36ed64..fc0a110 100644 --- a/assets/vue/views/TemplateEditView.vue +++ b/assets/vue/views/TemplateEditView.vue @@ -162,6 +162,7 @@ import AdminLayout from '../layouts/AdminLayout.vue' import CkEditorField from '../components/base/CkEditorField.vue' import { templateClient } from '../api' import { Requests } from "@tatevikgr/rest-api-client"; +import { useApiValidationErrors } from '../composables/useApiValidationErrors' const route = useRoute() const router = useRouter() @@ -258,35 +259,7 @@ const validationFieldLabels = { check_external_images: 'Check external images' } -const normalizeFieldName = (fieldPath = '') => { - if (validationFieldLabels[fieldPath]) return validationFieldLabels[fieldPath] - - const fallback = String(fieldPath) - .split('.') - .pop() - ?.replace(/\[\d+]/g, '') - ?.replace(/_/g, ' ') - ?.replace(/([a-z])([A-Z])/g, '$1 $2') - ?.trim() - - if (!fallback) return 'Field' - return fallback.charAt(0).toUpperCase() + fallback.slice(1) -} - -const formatValidationErrors = (error) => { - const responseData = error?.responseData - const messages = [] - - if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { - Object.entries(responseData).forEach(([field, rawMessage]) => { - if (!rawMessage) return - const text = Array.isArray(rawMessage) ? rawMessage.join(' ') : String(rawMessage) - messages.push(`${normalizeFieldName(field)}: ${text}`) - }) - } - - return [...new Set(messages)] -} +const {formatValidationErrors} = useApiValidationErrors(validationFieldLabels) const saveTemplate = async () => { if (!isCreateMode.value && (!Number.isFinite(templateId.value) || templateId.value <= 0)) { From 31c367f93ea73969d42b3fc3d8cdebb9d943358e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 28 Sep 2026 11:30:12 +0400 Subject: [PATCH 13/21] feat: implement useAsyncAction composable for improved async action handling --- assets/vue/composables/useAnalyticsData.js | 36 +++----- assets/vue/composables/useAsyncAction.js | 42 ++++++++++ assets/vue/composables/useDashboardData.js | 96 ++++++---------------- assets/vue/views/CampaignEditView.vue | 26 +++--- assets/vue/views/ListSubscribersView.vue | 45 +++++----- assets/vue/views/TemplateEditView.vue | 39 +++++---- 6 files changed, 133 insertions(+), 151 deletions(-) create mode 100644 assets/vue/composables/useAsyncAction.js diff --git a/assets/vue/composables/useAnalyticsData.js b/assets/vue/composables/useAnalyticsData.js index 738e6a7..a925fb8 100644 --- a/assets/vue/composables/useAnalyticsData.js +++ b/assets/vue/composables/useAnalyticsData.js @@ -1,25 +1,15 @@ import { ref } from 'vue' import { statisticsClient } from '../api' +import { useAsyncAction } from './useAsyncAction' -const isLoading = ref(true) -const hasLoaded = ref(false) -const errorMessage = ref('') const campaignStatistics = ref([]) const viewOpens = ref([]) const topDomains = ref([]) const domainConfirmation = ref(null) const topLocalParts = ref([]) -const loaded = ref(false) -const loadAnalytics = async () => { - if (loaded.value) { - return - } - - isLoading.value = true - errorMessage.value = '' - - try { +const analyticsAction = useAsyncAction( + async () => { const [ campaignResponse, viewOpensResponse, @@ -39,26 +29,20 @@ const loadAnalytics = async () => { topDomains.value = topDomainsResponse?.items ?? [] domainConfirmation.value = domainConfirmationResponse ?? null topLocalParts.value = topLocalPartsResponse?.items ?? [] - loaded.value = true - } catch (error) { - errorMessage.value = 'Failed to load analytics.' - console.error('Failed to load analytics:', error) - } finally { - isLoading.value = false - hasLoaded.value = true - } -} + }, + { once: true, errorMessage: 'Failed to load analytics.', logLabel: 'Failed to load analytics:' } +) export function useAnalyticsData() { return { - isLoading, - hasLoaded, - errorMessage, + isLoading: analyticsAction.loading, + hasLoaded: analyticsAction.settled, + errorMessage: analyticsAction.error, campaignStatistics, viewOpens, topDomains, domainConfirmation, topLocalParts, - loadAnalytics, + loadAnalytics: analyticsAction.run, } } \ No newline at end of file diff --git a/assets/vue/composables/useAsyncAction.js b/assets/vue/composables/useAsyncAction.js new file mode 100644 index 0000000..3085d9e --- /dev/null +++ b/assets/vue/composables/useAsyncAction.js @@ -0,0 +1,42 @@ +import { ref } from 'vue' + +export function useAsyncAction(action, options = {}) { + const { + once = false, + errorMessage = 'Something went wrong.', + logLabel, + onError, + } = options + + const loading = ref(false) + const error = ref('') + const loaded = ref(false) + const settled = ref(false) + + const run = async (...args) => { + if (loading.value || (once && loaded.value)) { + return undefined + } + + loading.value = true + error.value = '' + + try { + const result = await action(...args) + loaded.value = true + return result + } catch (err) { + if (logLabel) { + console.error(logLabel, err) + } + error.value = typeof errorMessage === 'function' ? errorMessage(err) : errorMessage + onError?.(err) + return undefined + } finally { + loading.value = false + settled.value = true + } + } + + return { loading, error, loaded, settled, run } +} diff --git a/assets/vue/composables/useDashboardData.js b/assets/vue/composables/useDashboardData.js index 3a5159b..3733f2f 100644 --- a/assets/vue/composables/useDashboardData.js +++ b/assets/vue/composables/useDashboardData.js @@ -1,5 +1,6 @@ import { ref } from 'vue' import { statisticsClient } from '../api' +import { useAsyncAction } from './useAsyncAction' const defaultChart = () => ({ labels: [], @@ -10,19 +11,8 @@ const defaultChart = () => ({ }) const summary = ref(null) -const summaryLoading = ref(false) -const summaryError = ref('') -const summaryLoaded = ref(false) - const recentCampaigns = ref([]) -const recentCampaignsLoading = ref(false) -const recentCampaignsError = ref('') -const recentCampaignsLoaded = ref(false) - const chart = ref(defaultChart()) -const chartLoading = ref(false) -const chartError = ref('') -const chartLoaded = ref(false) const formatChartLabel = (dateValue) => { if (!dateValue) { @@ -40,54 +30,23 @@ const formatChartLabel = (dateValue) => { return new Intl.DateTimeFormat('en-US', { month: 'short', day: '2-digit' }).format(date) } -const loadSummary = async () => { - if (summaryLoaded.value || summaryLoading.value) { - return - } - - summaryLoading.value = true - summaryError.value = '' - - try { +const summaryAction = useAsyncAction( + async () => { summary.value = await statisticsClient.getDashboardSummary() - summaryLoaded.value = true - } catch (err) { - summaryError.value = 'Unable to load dashboard summary.' - console.error('Failed to load dashboard summary:', err) - } finally { - summaryLoading.value = false - } -} - -const loadRecentCampaigns = async () => { - if (recentCampaignsLoaded.value || recentCampaignsLoading.value) { - return - } + }, + { once: true, errorMessage: 'Unable to load dashboard summary.', logLabel: 'Failed to load dashboard summary:' } +) - recentCampaignsLoading.value = true - recentCampaignsError.value = '' - - try { +const recentCampaignsAction = useAsyncAction( + async () => { const response = await statisticsClient.getRecentCampaigns() recentCampaigns.value = response?.campaigns ?? [] - recentCampaignsLoaded.value = true - } catch (err) { - recentCampaignsError.value = 'Unable to load recent campaigns.' - console.error('Failed to load recent campaigns:', err) - } finally { - recentCampaignsLoading.value = false - } -} - -const loadChart = async () => { - if (chartLoaded.value || chartLoading.value) { - return - } - - chartLoading.value = true - chartError.value = '' + }, + { once: true, errorMessage: 'Unable to load recent campaigns.', logLabel: 'Failed to load recent campaigns:' } +) - try { +const chartAction = useAsyncAction( + async () => { const response = await statisticsClient.getCampaignPerformance() const points = response?.points ?? [] chart.value = { @@ -97,32 +56,27 @@ const loadChart = async () => { { name: 'Clicks', data: points.map((point) => point.clicks) }, ], } - chartLoaded.value = true - } catch (err) { - chartError.value = 'Unable to load campaign performance.' - console.error('Failed to load campaign performance:', err) - } finally { - chartLoading.value = false - } -} + }, + { once: true, errorMessage: 'Unable to load campaign performance.', logLabel: 'Failed to load campaign performance:' } +) const load = () => { - loadSummary() - loadRecentCampaigns() - loadChart() + summaryAction.run() + recentCampaignsAction.run() + chartAction.run() } export function useDashboardData() { return { summary, - summaryLoading, - summaryError, + summaryLoading: summaryAction.loading, + summaryError: summaryAction.error, recentCampaigns, - recentCampaignsLoading, - recentCampaignsError, + recentCampaignsLoading: recentCampaignsAction.loading, + recentCampaignsError: recentCampaignsAction.error, chart, - chartLoading, - chartError, + chartLoading: chartAction.loading, + chartError: chartAction.error, load, } } \ No newline at end of file diff --git a/assets/vue/views/CampaignEditView.vue b/assets/vue/views/CampaignEditView.vue index 7a67ad4..9acb14c 100644 --- a/assets/vue/views/CampaignEditView.vue +++ b/assets/vue/views/CampaignEditView.vue @@ -396,6 +396,7 @@ import AdminLayout from '../layouts/AdminLayout.vue' import CkEditorField from '../components/base/CkEditorField.vue' import {campaignClient, fetchAllLists, listMessagesClient, templateClient} from '../api' import {useApiValidationErrors} from '../composables/useApiValidationErrors' +import {useAsyncAction} from '../composables/useAsyncAction' const route = useRoute() const router = useRouter() @@ -414,8 +415,6 @@ const isCreateMode = computed(() => route.name === 'campaign-create') const activeCampaignId = computed(() => Number(campaign.value?.id) || campaignIdFromRoute.value) const pageTitle = computed(() => isCreateMode.value ? 'Create Campaign' : `Edit Campaign #${campaignIdFromRoute.value}`) const currentStep = ref(1) -const isLoading = ref(true) -const loadError = ref('') const isSaving = ref(false) const isSendingTest = ref(false) const isQueueing = ref(false) @@ -587,11 +586,8 @@ const fillForm = (campaignValue) => { } } -const loadCampaignData = async () => { - isLoading.value = true - loadError.value = '' - - try { +const loadCampaignDataAction = useAsyncAction( + async () => { if (isCreateMode.value) { const mailingListsResponse = await fetchAllLists() @@ -632,13 +628,17 @@ const loadCampaignData = async () => { associatedListIds.value = linkedIds selectedListIds.value = [...linkedIds] - } catch (error) { - console.error('Failed to load campaign data for editing:', error) - loadError.value = error?.message || 'Failed to load campaign data.' - } finally { - isLoading.value = false + }, + { + errorMessage: (error) => error?.message || 'Failed to load campaign data.', + logLabel: 'Failed to load campaign data for editing:', } -} +) + +const isLoading = loadCampaignDataAction.loading +const loadError = loadCampaignDataAction.error + +const loadCampaignData = () => loadCampaignDataAction.run() const buildCampaignPayload = () => { const currentCampaign = campaign.value diff --git a/assets/vue/views/ListSubscribersView.vue b/assets/vue/views/ListSubscribersView.vue index 562eeef..e1e952b 100644 --- a/assets/vue/views/ListSubscribersView.vue +++ b/assets/vue/views/ListSubscribersView.vue @@ -257,6 +257,7 @@ import ListSubscribersExportPanel from '../components/lists/ListSubscribersExpor import BaseIcon from '../components/base/BaseIcon.vue' import BaseBadge from '../components/base/BaseBadge.vue' import client, {subscriptionClient} from '../api' +import {useAsyncAction} from '../composables/useAsyncAction' const route = useRoute() const listId = computed(() => Number(route.params.listId)) @@ -265,9 +266,7 @@ const listName = ref(typeof route.query.listName === 'string' ? route.query.list const subscribers = ref([]) const total = ref(0) const pageSize = ref(10) -const loading = ref(false) const actionLoading = ref(false) -const errorMessage = ref('') const actionError = ref('') const actionMessage = ref('') const nextCursor = ref(null) @@ -387,33 +386,37 @@ const loadAvailableLists = async () => { availableLists.value = lists } -const fetchSubscribers = async (cursor = null) => { - if (!Number.isInteger(listId.value) || listId.value <= 0) { - errorMessage.value = 'Invalid list ID.' - subscribers.value = [] - return - } - - loading.value = true - errorMessage.value = '' - - try { +const fetchSubscribersAction = useAsyncAction( + async (cursor = null) => { const response = await subscriptionClient.getSubscribersOfList(listId.value, cursor, pageSize.value) subscribers.value = Array.isArray(response.items) ? response.items : [] total.value = response.pagination?.total ?? subscribers.value.length nextCursor.value = response.pagination?.nextCursor ?? null clearSelection() return true - } catch (error) { - console.error('Failed to load list subscribers:', error) - errorMessage.value = 'Failed to load subscribers for this list.' + }, + { + errorMessage: 'Failed to load subscribers for this list.', + logLabel: 'Failed to load list subscribers:', + onError: () => { + subscribers.value = [] + nextCursor.value = null + clearSelection() + }, + } +) + +const loading = fetchSubscribersAction.loading +const errorMessage = fetchSubscribersAction.error + +const fetchSubscribers = async (cursor = null) => { + if (!Number.isInteger(listId.value) || listId.value <= 0) { + errorMessage.value = 'Invalid list ID.' subscribers.value = [] - nextCursor.value = null - clearSelection() - return false - } finally { - loading.value = false + return } + + return fetchSubscribersAction.run(cursor) } const refreshCurrentPage = async () => { diff --git a/assets/vue/views/TemplateEditView.vue b/assets/vue/views/TemplateEditView.vue index fc0a110..58968c5 100644 --- a/assets/vue/views/TemplateEditView.vue +++ b/assets/vue/views/TemplateEditView.vue @@ -163,12 +163,11 @@ import CkEditorField from '../components/base/CkEditorField.vue' import { templateClient } from '../api' import { Requests } from "@tatevikgr/rest-api-client"; import { useApiValidationErrors } from '../composables/useApiValidationErrors' +import { useAsyncAction } from '../composables/useAsyncAction' const route = useRoute() const router = useRouter() -const isLoading = ref(false) -const loadError = ref('') const isSaving = ref(false) const saveError = ref('') const saveErrors = ref([]) @@ -195,6 +194,23 @@ const saveButtonLabel = computed(() => { return isCreateMode.value ? 'Create' : 'Save' }) +const loadTemplateAction = useAsyncAction( + async () => { + const template = await templateClient.getTemplate(templateId.value) + form.value.title = template?.title || '' + form.value.listOrder = template?.listOrder !== null && template?.listOrder !== undefined + ? String(template.listOrder) + : '' + form.value.content = template?.content || '' + form.value.text = template?.text || '' + form.value.file = null + }, + { errorMessage: 'Failed to load template.', logLabel: 'Failed to load template:' } +) + +const isLoading = loadTemplateAction.loading +const loadError = loadTemplateAction.error + const loadTemplate = async () => { if (isCreateMode.value) { form.value.title = '' @@ -211,24 +227,7 @@ const loadTemplate = async () => { return } - isLoading.value = true - loadError.value = '' - - try { - const template = await templateClient.getTemplate(templateId.value) - form.value.title = template?.title || '' - form.value.listOrder = template?.listOrder !== null && template?.listOrder !== undefined - ? String(template.listOrder) - : '' - form.value.content = template?.content || '' - form.value.text = template?.text || '' - form.value.file = null - } catch (error) { - console.error('Failed to load template:', error) - loadError.value = 'Failed to load template.' - } finally { - isLoading.value = false - } + await loadTemplateAction.run() } const populateTextFromContent = () => { From 9c1a1abd70b4fd0afba173487f73c6bf0b8c58a6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 28 Sep 2026 13:09:40 +0400 Subject: [PATCH 14/21] feat: enhance AuthController to handle session tokens in about method --- .../lists/ListSubscribersExportPanel.vue | 15 +++++---- src/Controller/AuthController.php | 12 ++++++- tests/Unit/Controller/AuthControllerTest.php | 31 +++++++++++++++++-- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/assets/vue/components/lists/ListSubscribersExportPanel.vue b/assets/vue/components/lists/ListSubscribersExportPanel.vue index c647dc6..ec1be02 100644 --- a/assets/vue/components/lists/ListSubscribersExportPanel.vue +++ b/assets/vue/components/lists/ListSubscribersExportPanel.vue @@ -114,18 +114,17 @@