@orion-studios/cms 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analytics/react.d.ts +3 -1
- package/dist/analytics/react.js +9 -0
- package/dist/chunk-DPKXKG2S.js +8 -0
- package/dist/{chunk-ULE565KD.js → chunk-Z52R6XR7.js} +227 -72
- package/dist/forms/react.d.ts +12 -2
- package/dist/forms/react.js +6 -3
- package/dist/server/index.d.ts +17 -5
- package/dist/server/index.js +385 -75
- package/dist/studio/index.d.ts +4 -0
- package/dist/studio/index.js +54 -41
- package/package.json +1 -1
- package/sql/bootstrap.sql +43 -0
- package/sql/migrations/20260830143322_cms_analytics_unique_visitors.sql +43 -0
package/dist/studio/index.js
CHANGED
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
FORM_FIELD_TYPES,
|
|
5
5
|
FormRenderer,
|
|
6
6
|
getAutoReplyEmailFields
|
|
7
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-Z52R6XR7.js";
|
|
8
|
+
import "../chunk-DPKXKG2S.js";
|
|
8
9
|
|
|
9
10
|
// src/studio/Studio.tsx
|
|
10
|
-
import { useCallback as useCallback5, useEffect as useEffect11, useMemo as
|
|
11
|
+
import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo4, useState as useState12 } from "react";
|
|
11
12
|
|
|
12
13
|
// src/studio/api.ts
|
|
13
14
|
var StudioApiError = class extends Error {
|
|
@@ -121,6 +122,7 @@ function createStudioApi(options) {
|
|
|
121
122
|
listActivity: () => call("GET", "/activity"),
|
|
122
123
|
analytics: (params) => {
|
|
123
124
|
const query = new URLSearchParams();
|
|
125
|
+
if (params?.days) query.set("days", String(params.days));
|
|
124
126
|
if (params?.from) query.set("from", params.from);
|
|
125
127
|
if (params?.to) query.set("to", params.to);
|
|
126
128
|
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
@@ -382,7 +384,7 @@ function LoginView({ siteName, logoUrl }) {
|
|
|
382
384
|
}
|
|
383
385
|
|
|
384
386
|
// src/studio/views/AnalyticsView.tsx
|
|
385
|
-
import { useEffect as useEffect2,
|
|
387
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
386
388
|
import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
387
389
|
var RANGES = [
|
|
388
390
|
{ key: "7", label: "7 days", days: 7 },
|
|
@@ -459,7 +461,8 @@ function TrendChart({ trend }) {
|
|
|
459
461
|
}
|
|
460
462
|
function BarList({
|
|
461
463
|
rows,
|
|
462
|
-
max
|
|
464
|
+
max,
|
|
465
|
+
emptyMessage = "Nothing recorded yet."
|
|
463
466
|
}) {
|
|
464
467
|
const top = max ?? Math.max(1, ...rows.map((row) => row.value));
|
|
465
468
|
return /* @__PURE__ */ jsxs3("div", { className: "ost-barlist", children: [
|
|
@@ -474,7 +477,7 @@ function BarList({
|
|
|
474
477
|
] }) : null
|
|
475
478
|
] })
|
|
476
479
|
] }, row.label)),
|
|
477
|
-
rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children:
|
|
480
|
+
rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: emptyMessage }) : null
|
|
478
481
|
] });
|
|
479
482
|
}
|
|
480
483
|
function AnalyticsView({ api }) {
|
|
@@ -483,22 +486,22 @@ function AnalyticsView({ api }) {
|
|
|
483
486
|
const [error, setError] = useState3("");
|
|
484
487
|
useEffect2(() => {
|
|
485
488
|
const range = RANGES.find((entry) => entry.key === rangeKey) ?? RANGES[1];
|
|
486
|
-
|
|
487
|
-
const from = new Date(to.getTime() - range.days * 864e5);
|
|
489
|
+
let active = true;
|
|
488
490
|
setData(null);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
(
|
|
491
|
+
setError("");
|
|
492
|
+
api.analytics({ days: range.days }).then(
|
|
493
|
+
(summary) => {
|
|
494
|
+
if (active) setData(summary);
|
|
495
|
+
},
|
|
496
|
+
(e) => {
|
|
497
|
+
if (active) setError(e.message);
|
|
498
|
+
}
|
|
492
499
|
);
|
|
500
|
+
return () => {
|
|
501
|
+
active = false;
|
|
502
|
+
};
|
|
493
503
|
}, [api, rangeKey]);
|
|
494
|
-
const localHours =
|
|
495
|
-
if (!data) return [];
|
|
496
|
-
const offset = Math.round((/* @__PURE__ */ new Date()).getTimezoneOffset() / -60);
|
|
497
|
-
return data.hours.map((_, hour) => ({
|
|
498
|
-
hour,
|
|
499
|
-
count: data.hours[((hour - offset) % 24 + 24) % 24]
|
|
500
|
-
}));
|
|
501
|
-
}, [data]);
|
|
504
|
+
const localHours = data?.hours.map((count, hour) => ({ hour, count })) ?? [];
|
|
502
505
|
if (error) return /* @__PURE__ */ jsx3("div", { className: "ost-view", children: /* @__PURE__ */ jsx3("div", { className: "ost-error", children: error }) });
|
|
503
506
|
const kpis = data?.kpis ?? null;
|
|
504
507
|
const previous = data?.previous;
|
|
@@ -551,7 +554,7 @@ function AnalyticsView({ api }) {
|
|
|
551
554
|
/* @__PURE__ */ jsx3(
|
|
552
555
|
Kpi,
|
|
553
556
|
{
|
|
554
|
-
label: "
|
|
557
|
+
label: "Repeat-day visitors",
|
|
555
558
|
previous: previous ? { current: kpis.returningVisitors, prior: previous.returningVisitors } : void 0,
|
|
556
559
|
value: String(kpis.returningVisitors)
|
|
557
560
|
}
|
|
@@ -573,7 +576,7 @@ function AnalyticsView({ api }) {
|
|
|
573
576
|
}
|
|
574
577
|
),
|
|
575
578
|
/* @__PURE__ */ jsx3(Kpi, { label: "Pages per visitor", value: kpis.pagesPerVisitor.toFixed(1) }),
|
|
576
|
-
/* @__PURE__ */ jsx3(Kpi, { label: "
|
|
579
|
+
/* @__PURE__ */ jsx3(Kpi, { label: "Attributed visit conversion rate", value: pct(kpis.conversionRate) }),
|
|
577
580
|
/* @__PURE__ */ jsx3(
|
|
578
581
|
Kpi,
|
|
579
582
|
{
|
|
@@ -584,15 +587,17 @@ function AnalyticsView({ api }) {
|
|
|
584
587
|
)
|
|
585
588
|
] }),
|
|
586
589
|
/* @__PURE__ */ jsxs3("p", { className: "ost-muted ost-analytics-note", children: [
|
|
587
|
-
"
|
|
588
|
-
kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : ""
|
|
590
|
+
"Traffic counts cover consented tracked visits. Requests sent includes every stored submission. Repeat-day visitors use the pseudonymous cookie only after the visitor accepts analytics.",
|
|
591
|
+
kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : "",
|
|
592
|
+
` ${kpis.attributedSubmits} of ${kpis.formSubmits} requests were linked to a tracked visit.`,
|
|
593
|
+
` Ranges use site-local calendar days including today. Dates and hours use ${data.timeZone}.`
|
|
589
594
|
] }),
|
|
590
595
|
/* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
|
|
591
596
|
/* @__PURE__ */ jsxs3("h3", { children: [
|
|
592
597
|
"Daily visitors ",
|
|
593
598
|
/* @__PURE__ */ jsxs3("span", { className: "ost-trend-key", children: [
|
|
594
599
|
"\u25A0 visitors ",
|
|
595
|
-
/* @__PURE__ */ jsx3("em", { children: "\u25A0
|
|
600
|
+
/* @__PURE__ */ jsx3("em", { children: "\u25A0 verified actions" })
|
|
596
601
|
] })
|
|
597
602
|
] }),
|
|
598
603
|
/* @__PURE__ */ jsx3(TrendChart, { trend: data.trend })
|
|
@@ -606,26 +611,27 @@ function AnalyticsView({ api }) {
|
|
|
606
611
|
rows: data.pages.slice(0, 12).map((page) => ({
|
|
607
612
|
label: page.path === "/" ? "Home" : page.path,
|
|
608
613
|
value: page.views,
|
|
609
|
-
detail: page.conversions > 0 ? `\xB7 ${page.conversions}
|
|
614
|
+
detail: page.conversions > 0 ? `\xB7 ${page.conversions} linked actions` : void 0
|
|
610
615
|
}))
|
|
611
616
|
}
|
|
612
617
|
)
|
|
613
618
|
] }),
|
|
614
619
|
/* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
|
|
615
|
-
/* @__PURE__ */ jsx3("h3", { children: "Where
|
|
620
|
+
/* @__PURE__ */ jsx3("h3", { children: "Where tracked visits come from" }),
|
|
616
621
|
/* @__PURE__ */ jsx3(
|
|
617
622
|
BarList,
|
|
618
623
|
{
|
|
619
624
|
rows: data.sources.map((source) => ({
|
|
620
625
|
label: source.source,
|
|
621
626
|
value: source.sessions,
|
|
622
|
-
detail: source.conversions > 0 ? `\xB7 ${source.conversions}
|
|
627
|
+
detail: source.conversions > 0 ? `\xB7 ${source.conversions} linked actions` : void 0
|
|
623
628
|
}))
|
|
624
629
|
}
|
|
625
630
|
)
|
|
626
631
|
] }),
|
|
627
632
|
/* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
|
|
628
633
|
/* @__PURE__ */ jsx3("h3", { children: "Form funnel" }),
|
|
634
|
+
/* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Each step counts unique tracked visits, not raw events. A view requires the first field to remain 75% visible for 2 seconds, or direct field interaction. Starts and submissions supply the minimum when a view event is unavailable. Legacy form loads are excluded." }),
|
|
629
635
|
data.forms.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "No form activity yet." }) : null,
|
|
630
636
|
data.forms.map((form) => /* @__PURE__ */ jsxs3("div", { className: "ost-funnel", children: [
|
|
631
637
|
/* @__PURE__ */ jsx3("strong", { children: form.form }),
|
|
@@ -634,7 +640,7 @@ function AnalyticsView({ api }) {
|
|
|
634
640
|
{
|
|
635
641
|
max: Math.max(form.views, 1),
|
|
636
642
|
rows: [
|
|
637
|
-
{ label: "
|
|
643
|
+
{ label: "Viewed form", value: form.views },
|
|
638
644
|
{ label: "Started filling", value: form.starts },
|
|
639
645
|
{ label: "Submitted", value: form.submits }
|
|
640
646
|
]
|
|
@@ -643,7 +649,7 @@ function AnalyticsView({ api }) {
|
|
|
643
649
|
] }, form.form))
|
|
644
650
|
] }),
|
|
645
651
|
/* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
|
|
646
|
-
/* @__PURE__ */ jsx3("h3", { children: "
|
|
652
|
+
/* @__PURE__ */ jsx3("h3", { children: "Tracked visit locations" }),
|
|
647
653
|
/* @__PURE__ */ jsx3(
|
|
648
654
|
BarList,
|
|
649
655
|
{
|
|
@@ -676,7 +682,11 @@ function AnalyticsView({ api }) {
|
|
|
676
682
|
": ",
|
|
677
683
|
device.sessions
|
|
678
684
|
] }, device.device)) }),
|
|
679
|
-
/* @__PURE__ */
|
|
685
|
+
/* @__PURE__ */ jsxs3("h4", { className: "ost-analytics-subhead", children: [
|
|
686
|
+
"Busiest hours (",
|
|
687
|
+
data.timeZone,
|
|
688
|
+
")"
|
|
689
|
+
] }),
|
|
680
690
|
/* @__PURE__ */ jsx3("div", { className: "ost-hours", children: localHours.map((entry) => {
|
|
681
691
|
const max = Math.max(1, ...localHours.map((h) => h.count));
|
|
682
692
|
return /* @__PURE__ */ jsx3(
|
|
@@ -684,14 +694,19 @@ function AnalyticsView({ api }) {
|
|
|
684
694
|
{
|
|
685
695
|
className: "ost-hour-bar",
|
|
686
696
|
style: { height: `${Math.max(entry.count / max * 100, 4)}%` },
|
|
687
|
-
title: `${entry.hour}:00
|
|
697
|
+
title: `${entry.hour}:00, ${entry.count} views`
|
|
688
698
|
},
|
|
689
699
|
entry.hour
|
|
690
700
|
);
|
|
691
701
|
}) }),
|
|
692
702
|
/* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Broken links (404s)" }),
|
|
693
|
-
|
|
694
|
-
|
|
703
|
+
/* @__PURE__ */ jsx3(
|
|
704
|
+
BarList,
|
|
705
|
+
{
|
|
706
|
+
emptyMessage: "No broken links recorded.",
|
|
707
|
+
rows: data.notFound.slice(0, 8).map((entry) => ({ label: entry.path, value: entry.count }))
|
|
708
|
+
}
|
|
709
|
+
)
|
|
695
710
|
] })
|
|
696
711
|
] })
|
|
697
712
|
] }) : null
|
|
@@ -739,9 +754,7 @@ function DashboardView({
|
|
|
739
754
|
useEffect3(() => {
|
|
740
755
|
api.dashboard().then(setData, (e) => setError(e.message));
|
|
741
756
|
if (canReadAnalytics) {
|
|
742
|
-
|
|
743
|
-
const from = new Date(to.getTime() - 7 * 864e5);
|
|
744
|
-
api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
|
|
757
|
+
api.analytics({ days: 7 }).then(
|
|
745
758
|
(summary) => setWeek({
|
|
746
759
|
visitors: summary.kpis.visitors,
|
|
747
760
|
calls: summary.kpis.calls,
|
|
@@ -1445,7 +1458,7 @@ function FieldEditor({
|
|
|
1445
1458
|
}
|
|
1446
1459
|
|
|
1447
1460
|
// src/studio/views/PageEditor.tsx
|
|
1448
|
-
import { useCallback as useCallback2, useEffect as useEffect6, useMemo as
|
|
1461
|
+
import { useCallback as useCallback2, useEffect as useEffect6, useMemo as useMemo2, useRef, useState as useState7 } from "react";
|
|
1449
1462
|
|
|
1450
1463
|
// src/studio/inline.ts
|
|
1451
1464
|
function collectInlineTargets(definition, data) {
|
|
@@ -2173,7 +2186,7 @@ function PageEditor({
|
|
|
2173
2186
|
const selectedIndex = selectedId ? layout.findIndex((block) => block.id === selectedId) : -1;
|
|
2174
2187
|
const selected = selectedIndex >= 0 ? layout[selectedIndex] : null;
|
|
2175
2188
|
const selectedDefinition = selected ? registry.get(selected.type) : void 0;
|
|
2176
|
-
const changedSincePublish =
|
|
2189
|
+
const changedSincePublish = useMemo2(() => {
|
|
2177
2190
|
if (!page) return false;
|
|
2178
2191
|
if (page.status !== "published") return true;
|
|
2179
2192
|
return dirty || title !== (page.published_title ?? "") || JSON.stringify(seo) !== JSON.stringify(page.published_seo ?? {}) || JSON.stringify(layout) !== JSON.stringify(page.published_layout ?? []);
|
|
@@ -2346,7 +2359,7 @@ function PageEditor({
|
|
|
2346
2359
|
setDirty(false);
|
|
2347
2360
|
setMessage("Version restored into draft.");
|
|
2348
2361
|
};
|
|
2349
|
-
const palette =
|
|
2362
|
+
const palette = useMemo2(() => registry.palette(), [registry]);
|
|
2350
2363
|
const insertBlock = (type, at) => {
|
|
2351
2364
|
const instance = registry.createInstance(type);
|
|
2352
2365
|
const next = [...layout];
|
|
@@ -3312,7 +3325,7 @@ function UsersView({ api, meId }) {
|
|
|
3312
3325
|
}
|
|
3313
3326
|
|
|
3314
3327
|
// src/studio/views/views.tsx
|
|
3315
|
-
import { useEffect as useEffect10, useMemo as
|
|
3328
|
+
import { useEffect as useEffect10, useMemo as useMemo3, useState as useState11 } from "react";
|
|
3316
3329
|
import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
3317
3330
|
var pageStatus = (page) => {
|
|
3318
3331
|
if (page.publish_at) return { label: "scheduled", live: false };
|
|
@@ -3399,7 +3412,7 @@ function MediaView({
|
|
|
3399
3412
|
const [selected, setSelected] = useState11(null);
|
|
3400
3413
|
const [search, setSearch] = useState11("");
|
|
3401
3414
|
const [error, setError] = useState11("");
|
|
3402
|
-
const visible =
|
|
3415
|
+
const visible = useMemo3(() => {
|
|
3403
3416
|
const term = search.trim().toLowerCase();
|
|
3404
3417
|
if (!term) return media;
|
|
3405
3418
|
return media.filter(
|
|
@@ -3740,7 +3753,7 @@ var NAV = [
|
|
|
3740
3753
|
];
|
|
3741
3754
|
function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
3742
3755
|
const { session, loading, getToken, signOut } = useStudioSession();
|
|
3743
|
-
const api =
|
|
3756
|
+
const api = useMemo4(() => createStudioApi({ getToken }), [getToken]);
|
|
3744
3757
|
useEffect11(() => {
|
|
3745
3758
|
try {
|
|
3746
3759
|
localStorage.setItem("orion-analytics-exclude", "1");
|
package/package.json
CHANGED
package/sql/bootstrap.sql
CHANGED
|
@@ -1074,6 +1074,49 @@ begin
|
|
|
1074
1074
|
end;
|
|
1075
1075
|
$$;
|
|
1076
1076
|
|
|
1077
|
+
-- Aggregate-only visitor count for care-report sharing. The service role
|
|
1078
|
+
-- invokes it, and raw visitor and session identifiers never leave PostgreSQL.
|
|
1079
|
+
create or replace function public.cms_analytics_unique_visitors(
|
|
1080
|
+
p_from timestamptz,
|
|
1081
|
+
p_to timestamptz
|
|
1082
|
+
) returns bigint
|
|
1083
|
+
language sql
|
|
1084
|
+
stable
|
|
1085
|
+
security invoker
|
|
1086
|
+
set search_path = ''
|
|
1087
|
+
as $$
|
|
1088
|
+
with session_identities as (
|
|
1089
|
+
select
|
|
1090
|
+
session_key,
|
|
1091
|
+
max(nullif(visitor_key, '')) as visitor_key,
|
|
1092
|
+
min(id) as first_event_id
|
|
1093
|
+
from public.cms_events
|
|
1094
|
+
where created_at >= p_from
|
|
1095
|
+
and created_at <= p_to
|
|
1096
|
+
group by session_key
|
|
1097
|
+
)
|
|
1098
|
+
select count(distinct coalesce(
|
|
1099
|
+
visitor_key,
|
|
1100
|
+
nullif(session_key, ''),
|
|
1101
|
+
'event:' || first_event_id::text
|
|
1102
|
+
))::bigint
|
|
1103
|
+
from session_identities;
|
|
1104
|
+
$$;
|
|
1105
|
+
|
|
1106
|
+
revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from public;
|
|
1107
|
+
do $analytics_aggregate_grants$
|
|
1108
|
+
begin
|
|
1109
|
+
if exists (select 1 from pg_roles where rolname = 'anon') then
|
|
1110
|
+
revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from anon;
|
|
1111
|
+
end if;
|
|
1112
|
+
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
1113
|
+
revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from authenticated;
|
|
1114
|
+
end if;
|
|
1115
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
1116
|
+
grant execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) to service_role;
|
|
1117
|
+
end if;
|
|
1118
|
+
end $analytics_aggregate_grants$;
|
|
1119
|
+
|
|
1077
1120
|
-- 3) Row-level security -------------------------------------------------------
|
|
1078
1121
|
-- Defense-in-depth. The app's API layer (service role) is the primary
|
|
1079
1122
|
-- enforcement point; anonymous access is read-only and published-only.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Aggregate-only visitor count for care-report sharing. Raw visitor and
|
|
2
|
+
-- session identifiers stay inside PostgreSQL.
|
|
3
|
+
create or replace function public.cms_analytics_unique_visitors(
|
|
4
|
+
p_from timestamptz,
|
|
5
|
+
p_to timestamptz
|
|
6
|
+
) returns bigint
|
|
7
|
+
language sql
|
|
8
|
+
stable
|
|
9
|
+
security invoker
|
|
10
|
+
set search_path = ''
|
|
11
|
+
as $$
|
|
12
|
+
with session_identities as (
|
|
13
|
+
select
|
|
14
|
+
session_key,
|
|
15
|
+
max(nullif(visitor_key, '')) as visitor_key,
|
|
16
|
+
min(id) as first_event_id
|
|
17
|
+
from public.cms_events
|
|
18
|
+
where created_at >= p_from
|
|
19
|
+
and created_at <= p_to
|
|
20
|
+
group by session_key
|
|
21
|
+
)
|
|
22
|
+
select count(distinct coalesce(
|
|
23
|
+
visitor_key,
|
|
24
|
+
nullif(session_key, ''),
|
|
25
|
+
'event:' || first_event_id::text
|
|
26
|
+
))::bigint
|
|
27
|
+
from session_identities;
|
|
28
|
+
$$;
|
|
29
|
+
|
|
30
|
+
revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from public;
|
|
31
|
+
|
|
32
|
+
do $analytics_aggregate_grants$
|
|
33
|
+
begin
|
|
34
|
+
if exists (select 1 from pg_roles where rolname = 'anon') then
|
|
35
|
+
revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from anon;
|
|
36
|
+
end if;
|
|
37
|
+
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
38
|
+
revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from authenticated;
|
|
39
|
+
end if;
|
|
40
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
41
|
+
grant execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) to service_role;
|
|
42
|
+
end if;
|
|
43
|
+
end $analytics_aggregate_grants$;
|