@ossy/analytics 3.11.1 → 3.12.0
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/README.md +14 -11
- package/SPEC.md +177 -0
- package/package.json +11 -6
- package/src/AnalyticsProductHome.jsx +139 -33
- package/src/Definition.js +1 -1
- package/src/analytics-home-content.js +4 -4
- package/src/analytics.page.jsx +1 -1
- package/src/create-page-view.action.js +1 -0
- package/src/create-page-view.task.js +55 -0
- package/src/create-page-view.task.spec.js +170 -0
- package/src/en.translations.json +25 -14
- package/src/get-page-view-stats.action.js +1 -0
- package/src/get-page-view-stats.task.js +129 -0
- package/src/get-page-view-stats.task.spec.js +146 -0
- package/src/host-from-domain-resource.js +9 -0
- package/src/host-from-domain-resource.spec.js +15 -0
- package/src/index.js +2 -0
- package/src/page-view-location.aggregate.js +94 -0
- package/src/page-view-location.aggregate.spec.js +103 -0
- package/src/resolve-country-from-ip.js +85 -0
- package/src/resolve-country-from-ip.spec.js +52 -0
- package/src/resolve-page-view-workspace.js +110 -0
- package/src/resolve-page-view-workspace.spec.js +101 -0
- package/src/sv.translations.json +25 -14
- package/src/usePageViewStats.js +11 -6
- package/src/view-analytics.flow.js +4 -4
- package/src/useWorkspaceKpis.js +0 -45
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const findOne = jest.fn()
|
|
4
|
+
const commitResource = jest.fn()
|
|
5
|
+
const ofMock = jest.fn()
|
|
6
|
+
|
|
7
|
+
jest.unstable_mockModule('nanoid', () => ({ nanoid: () => 'res_1' }))
|
|
8
|
+
|
|
9
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
10
|
+
Aggregate: {
|
|
11
|
+
Of: ofMock,
|
|
12
|
+
View: () => (saved) => saved,
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.unstable_mockModule('@ossy/workspaces/server', () => ({
|
|
17
|
+
Workspace: { name: 'Workspace' },
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('@ossy/resources/server', () => ({
|
|
21
|
+
ResourceStream: { Collection: { findOne } },
|
|
22
|
+
commitResource,
|
|
23
|
+
ResourcesEvents: {
|
|
24
|
+
Created ({ resourceId, schemaId, createdBy, belongsTo, location, name, access, content, ...rest }) {
|
|
25
|
+
return {
|
|
26
|
+
type: schemaId,
|
|
27
|
+
resourceId,
|
|
28
|
+
event: 'Created',
|
|
29
|
+
createdBy,
|
|
30
|
+
payload: {
|
|
31
|
+
belongsTo,
|
|
32
|
+
location,
|
|
33
|
+
name,
|
|
34
|
+
access,
|
|
35
|
+
content: content ?? {},
|
|
36
|
+
...rest,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const { run } = await import('./create-page-view.task.js')
|
|
44
|
+
|
|
45
|
+
describe('create-page-view task', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
findOne.mockReset()
|
|
48
|
+
commitResource.mockReset()
|
|
49
|
+
ofMock.mockReset()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns 200-shaped no-op when the Host is not registered', async () => {
|
|
53
|
+
findOne.mockResolvedValue(null)
|
|
54
|
+
const result = await run({
|
|
55
|
+
payload: { path: '/', ip: '1.2.3.4', countryCode: 'US' },
|
|
56
|
+
req: { hostname: 'unknown.example', ip: '9.9.9.9', headers: {} },
|
|
57
|
+
})
|
|
58
|
+
expect(result).toEqual({ ok: true, recorded: false })
|
|
59
|
+
expect(commitResource).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns no-op for unregistered localhost without leaking the registry', async () => {
|
|
63
|
+
findOne.mockResolvedValue(null)
|
|
64
|
+
const result = await run({
|
|
65
|
+
payload: { path: '/' },
|
|
66
|
+
req: { hostname: 'localhost', workspaceId: 'ws-cookie', headers: {} },
|
|
67
|
+
})
|
|
68
|
+
expect(result).toEqual({ ok: true, recorded: false })
|
|
69
|
+
expect(findOne).toHaveBeenCalled()
|
|
70
|
+
expect(commitResource).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('records a view when localhost is a registered Host', async () => {
|
|
74
|
+
findOne.mockResolvedValue({ state: { belongsTo: 'ws-local' } })
|
|
75
|
+
ofMock.mockImplementation(() => ({
|
|
76
|
+
then (fn) {
|
|
77
|
+
return Promise.resolve(fn({ id: 'ws-local' }))
|
|
78
|
+
},
|
|
79
|
+
}))
|
|
80
|
+
commitResource.mockResolvedValue({ id: 'res_1' })
|
|
81
|
+
|
|
82
|
+
const result = await run({
|
|
83
|
+
payload: { path: '/' },
|
|
84
|
+
req: { hostname: 'localhost', headers: { origin: 'http://localhost:3006' } },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual({ ok: true, recorded: true, id: 'res_1' })
|
|
88
|
+
expect(commitResource.mock.calls[0][0].payload.content.host).toBe('localhost')
|
|
89
|
+
expect(commitResource.mock.calls[0][0].payload.belongsTo).toBe('ws-local')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('records a view for a registered Host and stores server host', async () => {
|
|
93
|
+
findOne.mockResolvedValue({ state: { belongsTo: 'ws-ossy' } })
|
|
94
|
+
ofMock.mockImplementation(() => ({
|
|
95
|
+
then (fn) {
|
|
96
|
+
return Promise.resolve(fn({ id: 'ws-ossy' }))
|
|
97
|
+
},
|
|
98
|
+
}))
|
|
99
|
+
commitResource.mockResolvedValue({ id: 'res_1' })
|
|
100
|
+
|
|
101
|
+
const result = await run({
|
|
102
|
+
payload: { path: '/pricing', visitorId: 'v1', workspaceId: 'ws-spoof', eventAt: 1 },
|
|
103
|
+
req: {
|
|
104
|
+
hostname: 'ossy.se',
|
|
105
|
+
headers: { 'user-agent': 'Mozilla' },
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual({ ok: true, recorded: true, id: 'res_1' })
|
|
110
|
+
const event = commitResource.mock.calls[0][0]
|
|
111
|
+
expect(event.payload.belongsTo).toBe('ws-ossy')
|
|
112
|
+
expect(event.payload.content.host).toBe('ossy.se')
|
|
113
|
+
expect(event.payload.content.path).toBe('/pricing')
|
|
114
|
+
expect(event.payload.content.workspaceId).toBeUndefined()
|
|
115
|
+
expect(event.payload.content.eventAt).toBeUndefined()
|
|
116
|
+
expect(event.payload.name).toBe('/pricing')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('attributes Origin host, not the API hostname', async () => {
|
|
120
|
+
findOne.mockResolvedValue({ state: { belongsTo: 'ws-customer' } })
|
|
121
|
+
ofMock.mockImplementation(() => ({
|
|
122
|
+
then (fn) {
|
|
123
|
+
return Promise.resolve(fn({ id: 'ws-customer' }))
|
|
124
|
+
},
|
|
125
|
+
}))
|
|
126
|
+
commitResource.mockResolvedValue({ id: 'res_1' })
|
|
127
|
+
|
|
128
|
+
const result = await run({
|
|
129
|
+
payload: { path: '/' },
|
|
130
|
+
req: {
|
|
131
|
+
hostname: 'ossy.se',
|
|
132
|
+
headers: { origin: 'https://www.customer.com', host: 'ossy.se' },
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(result.recorded).toBe(true)
|
|
137
|
+
expect(commitResource.mock.calls[0][0].payload.belongsTo).toBe('ws-customer')
|
|
138
|
+
expect(commitResource.mock.calls[0][0].payload.content.host).toBe('customer.com')
|
|
139
|
+
expect(findOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
140
|
+
$or: expect.arrayContaining([
|
|
141
|
+
{ 'state.name': { $in: ['customer.com', 'www.customer.com'] } },
|
|
142
|
+
]),
|
|
143
|
+
}))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not write client payload countryCode/ip onto content', async () => {
|
|
147
|
+
findOne.mockResolvedValue({ state: { belongsTo: 'ws-1' } })
|
|
148
|
+
ofMock.mockImplementation(() => ({
|
|
149
|
+
then (fn) {
|
|
150
|
+
return Promise.resolve(fn({ id: 'ws-1' }))
|
|
151
|
+
},
|
|
152
|
+
}))
|
|
153
|
+
commitResource.mockResolvedValue({ id: 'res_1' })
|
|
154
|
+
|
|
155
|
+
await run({
|
|
156
|
+
payload: {
|
|
157
|
+
path: '/pricing',
|
|
158
|
+
countryCode: 'US',
|
|
159
|
+
ip: '1.2.3.4',
|
|
160
|
+
},
|
|
161
|
+
req: { hostname: 'ossy.se', ip: '203.0.113.10', headers: {} },
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const content = commitResource.mock.calls[0][0].payload.content
|
|
165
|
+
expect(content).not.toHaveProperty('countryCode')
|
|
166
|
+
expect(content).not.toHaveProperty('ip')
|
|
167
|
+
expect(content.path).toBe('/pricing')
|
|
168
|
+
expect(content.host).toBe('ossy.se')
|
|
169
|
+
})
|
|
170
|
+
})
|
package/src/en.translations.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"analytics/home.documentTitle": "Analytics",
|
|
3
3
|
"analytics.home.title": "Analytics",
|
|
4
|
-
"analytics.home.description": "
|
|
4
|
+
"analytics.home.description": "Unique visitors and page views for a registered domain.",
|
|
5
5
|
"analytics.home.overview": "Overview",
|
|
6
6
|
"analytics.home.traffic": "Traffic",
|
|
7
7
|
"analytics.home.loading": "Loading analytics...",
|
|
8
8
|
"analytics.home.error": "Could not load analytics data.",
|
|
9
|
-
"analytics.home.
|
|
10
|
-
"analytics.home.
|
|
11
|
-
"analytics.home.
|
|
12
|
-
"analytics.home.
|
|
9
|
+
"analytics.home.empty": "Register a domain to see visitors and page views for that Host.",
|
|
10
|
+
"analytics.home.emptyEnableDomains": "Enable Domains for this workspace, then register a Host to see visitors.",
|
|
11
|
+
"analytics.home.addDomain": "Add a domain",
|
|
12
|
+
"analytics.home.enableDomains": "Enable domains",
|
|
13
|
+
"analytics.home.hostLabel": "Domain",
|
|
14
|
+
"analytics.home.uniqueVisitors": "Visitors (30 days)",
|
|
15
|
+
"analytics.home.totalPageViews": "Page views (30 days)",
|
|
13
16
|
"analytics.home.trackedPages": "Tracked pages (30 days)",
|
|
14
17
|
"analytics.home.daysWithTraffic": "Days with traffic",
|
|
15
18
|
"analytics.home.topPaths": "Top paths",
|
|
@@ -17,18 +20,26 @@
|
|
|
17
20
|
"analytics.home.unknownPath": "(unknown path)",
|
|
18
21
|
"analytics.home.dailyPageViews": "Daily page views",
|
|
19
22
|
"analytics.home.noDailyData": "No daily data yet.",
|
|
20
|
-
"analytics.home.
|
|
21
|
-
"analytics.home.
|
|
23
|
+
"analytics.home.locations": "Visitor locations",
|
|
24
|
+
"analytics.home.noLocationData": "No location data yet. Country is derived from visitor IPs (GeoLite2) when available — raw IPs are not shown.",
|
|
25
|
+
"analytics.home.cover.title": "See who visits your site",
|
|
26
|
+
"analytics.home.cover.text": "Unique visitors and page views for each domain you register — including people who never sign in.",
|
|
22
27
|
"analytics.home.cover.ctaPrimary": "Get started free",
|
|
23
|
-
"analytics.home.features.
|
|
24
|
-
"analytics.home.features.
|
|
25
|
-
"analytics.home.features.
|
|
26
|
-
"analytics.home.features.
|
|
28
|
+
"analytics.home.features.visitors.title": "Visitors",
|
|
29
|
+
"analytics.home.features.visitors.text": "Count everyone on a registered Host — anonymous visitors and signed-in members.",
|
|
30
|
+
"analytics.home.features.paths.title": "Top paths",
|
|
31
|
+
"analytics.home.features.paths.text": "See which pages people open most over the last 30 days.",
|
|
27
32
|
"analytics.home.features.traffic.title": "Traffic",
|
|
28
|
-
"analytics.home.features.traffic.text": "
|
|
33
|
+
"analytics.home.features.traffic.text": "Pick one domain at a time. Each Host has its own visitor, page-view, and location counts.",
|
|
29
34
|
"analytics.sales.overview": "Overview",
|
|
30
35
|
"analytics.sales.features": "Features",
|
|
31
36
|
"analytics.sales.enable": "Enable analytics",
|
|
32
|
-
"analytics.sales.enableDescription": "Activate analytics
|
|
33
|
-
"analytics.sales.enableError": "Could not enable analytics. Try again or contact support."
|
|
37
|
+
"analytics.sales.enableDescription": "Activate analytics to view visitors and page views for domains you register.",
|
|
38
|
+
"analytics.sales.enableError": "Could not enable analytics. Try again or contact support.",
|
|
39
|
+
"@ossy/analytics/actions/create-page-view.label": "Create page view",
|
|
40
|
+
"@ossy/analytics/actions/create-page-view.description": "Record a page view for a registered Host",
|
|
41
|
+
"@ossy/analytics/actions/get-page-view-stats.label": "Get page view stats",
|
|
42
|
+
"@ossy/analytics/actions/get-page-view-stats.description": "Fetch visitor and page-view stats for a registered Host",
|
|
43
|
+
"@ossy/analytics/actions/get-workspace-kpis.label": "Get workspace KPIs",
|
|
44
|
+
"@ossy/analytics/actions/get-workspace-kpis.description": "Member and resource counts for the current workspace"
|
|
34
45
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const metadata = { id: '@ossy/analytics/actions/get-page-view-stats', access: 'workspace' }
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Aggregate, getProjection } from '@ossy/event-store'
|
|
2
|
+
import { Workspace } from '@ossy/workspaces/server'
|
|
3
|
+
import { ResourceStream } from '@ossy/resources/server'
|
|
4
|
+
import {
|
|
5
|
+
normalizeHost,
|
|
6
|
+
workspaceIdForRegisteredHost,
|
|
7
|
+
} from './resolve-page-view-workspace.js'
|
|
8
|
+
import {
|
|
9
|
+
PageViewLocationProjection,
|
|
10
|
+
pageViewLocationScopeId,
|
|
11
|
+
} from './page-view-location.aggregate.js'
|
|
12
|
+
|
|
13
|
+
export const metadata = { id: '@ossy/analytics/tasks/get-page-view-stats' }
|
|
14
|
+
|
|
15
|
+
const PAGE_VIEW_SCHEMA_ID = '@ossy/web/schema/page-view'
|
|
16
|
+
const PAGE_VIEW_LOCATION = '/@ossy/analytics/page-views/'
|
|
17
|
+
|
|
18
|
+
function parseMillis(input, fallback) {
|
|
19
|
+
if (input === undefined || input === null || input === '') return fallback
|
|
20
|
+
const n = Number(input)
|
|
21
|
+
if (!Number.isFinite(n) || n <= 0) return fallback
|
|
22
|
+
return n
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Exported for unit tests — sum folded daily country buckets into byCountry.
|
|
27
|
+
* @param {Record<string, Record<string, number>> | undefined} byDay
|
|
28
|
+
*/
|
|
29
|
+
export function rollupByCountry (byDay, { from, to, countryLimit }) {
|
|
30
|
+
const fromDay = new Date(from).toISOString().slice(0, 10)
|
|
31
|
+
const toDay = new Date(to).toISOString().slice(0, 10)
|
|
32
|
+
const counts = new Map()
|
|
33
|
+
for (const [day, countries] of Object.entries(byDay ?? {})) {
|
|
34
|
+
if (day < fromDay || day > toDay) continue
|
|
35
|
+
for (const [rawCode, n] of Object.entries(countries ?? {})) {
|
|
36
|
+
const code = typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : ''
|
|
37
|
+
if (!/^[A-Z]{2}$/.test(code)) continue
|
|
38
|
+
const count = Number(n)
|
|
39
|
+
if (!Number.isFinite(count) || count <= 0) continue
|
|
40
|
+
counts.set(code, (counts.get(code) ?? 0) + count)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...counts.entries()]
|
|
44
|
+
.map(([countryCode, viewsCount]) => ({ countryCode, views: viewsCount }))
|
|
45
|
+
.sort((a, b) => b.views - a.views || a.countryCode.localeCompare(b.countryCode))
|
|
46
|
+
.slice(0, countryLimit)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function run({ payload, req }) {
|
|
50
|
+
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
51
|
+
if (!workspaceId) throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
52
|
+
|
|
53
|
+
const host = normalizeHost(payload?.host ?? payload?.domain ?? req?.query?.host)
|
|
54
|
+
if (!host) throw Object.assign(new Error('host is required'), { status: 400 })
|
|
55
|
+
|
|
56
|
+
const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
|
|
57
|
+
const owner = await workspaceIdForRegisteredHost(ResourceStream.Collection, host)
|
|
58
|
+
if (owner !== workspace.id) {
|
|
59
|
+
throw Object.assign(new Error('Host not found'), { status: 404 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
const defaultFrom = now - (30 * 24 * 60 * 60 * 1000)
|
|
64
|
+
const from = parseMillis(payload?.from ?? req?.query?.from, defaultFrom)
|
|
65
|
+
const to = parseMillis(payload?.to ?? req?.query?.to, now)
|
|
66
|
+
const topLimit = Math.min(Math.max(Number(payload?.limit ?? req?.query?.limit ?? 10), 1), 50)
|
|
67
|
+
// Country rollup is bounded by ISO codes (~250), not the top-paths cap.
|
|
68
|
+
const countryLimit = Math.min(
|
|
69
|
+
Math.max(Number(payload?.countryLimit ?? req?.query?.countryLimit ?? 250), 1),
|
|
70
|
+
250,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const baseMatch = {
|
|
74
|
+
type: PAGE_VIEW_SCHEMA_ID,
|
|
75
|
+
'state.status': { $ne: 'removed' },
|
|
76
|
+
'state.belongsTo': workspace.id,
|
|
77
|
+
'state.location': PAGE_VIEW_LOCATION,
|
|
78
|
+
'state.content.host': host,
|
|
79
|
+
'state.created': { $gte: from, $lte: to },
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const locationScopeId = pageViewLocationScopeId(workspace.id, host)
|
|
83
|
+
|
|
84
|
+
const [dailyViews, topPaths, totals, unique, locationProjection] = await Promise.all([
|
|
85
|
+
Aggregate.Collection.aggregate([
|
|
86
|
+
{ $match: baseMatch },
|
|
87
|
+
{ $project: { date: { $dateToString: { format: '%Y-%m-%d', date: { $toDate: '$state.created' } } } } },
|
|
88
|
+
{ $group: { _id: '$date', views: { $sum: 1 } } },
|
|
89
|
+
{ $sort: { _id: 1 } },
|
|
90
|
+
]).toArray(),
|
|
91
|
+
Aggregate.Collection.aggregate([
|
|
92
|
+
{ $match: baseMatch },
|
|
93
|
+
{ $group: { _id: '$state.content.path', views: { $sum: 1 } } },
|
|
94
|
+
{ $sort: { views: -1 } },
|
|
95
|
+
{ $limit: topLimit },
|
|
96
|
+
{ $project: { _id: 0, path: '$_id', views: 1 } },
|
|
97
|
+
]).toArray(),
|
|
98
|
+
Aggregate.Collection.aggregate([
|
|
99
|
+
{ $match: baseMatch },
|
|
100
|
+
{ $count: 'views' },
|
|
101
|
+
]).toArray(),
|
|
102
|
+
Aggregate.Collection.aggregate([
|
|
103
|
+
{
|
|
104
|
+
$match: {
|
|
105
|
+
...baseMatch,
|
|
106
|
+
'state.content.visitorId': { $exists: true, $nin: [null, ''] },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{ $group: { _id: '$state.content.visitorId' } },
|
|
110
|
+
{ $count: 'visitors' },
|
|
111
|
+
]).toArray(),
|
|
112
|
+
locationScopeId
|
|
113
|
+
? getProjection(locationScopeId, PageViewLocationProjection.ProjectionId)
|
|
114
|
+
: Promise.resolve(null),
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
const byCountry = rollupByCountry(locationProjection?.byDay, { from, to, countryLimit })
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
host,
|
|
121
|
+
from,
|
|
122
|
+
to,
|
|
123
|
+
totalViews: totals?.[0]?.views || 0,
|
|
124
|
+
uniqueVisitors: unique?.[0]?.visitors || 0,
|
|
125
|
+
dailyViews: dailyViews.map(x => ({ date: x._id, views: x.views })),
|
|
126
|
+
topPaths,
|
|
127
|
+
byCountry,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const aggregate = jest.fn()
|
|
4
|
+
const ofMock = jest.fn()
|
|
5
|
+
const findOne = jest.fn()
|
|
6
|
+
const getProjection = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
9
|
+
Aggregate: {
|
|
10
|
+
Of: ofMock,
|
|
11
|
+
View: () => (saved) => saved,
|
|
12
|
+
Collection: { aggregate },
|
|
13
|
+
},
|
|
14
|
+
getProjection,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.unstable_mockModule('@ossy/workspaces/server', () => ({
|
|
18
|
+
Workspace: { name: 'Workspace' },
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.unstable_mockModule('@ossy/resources/server', () => ({
|
|
22
|
+
ResourceStream: { Collection: { findOne } },
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
jest.unstable_mockModule('./page-view-location.aggregate.js', () => ({
|
|
26
|
+
PageViewLocationProjection: {
|
|
27
|
+
ProjectionId: '@ossy/analytics/data/page-view-location',
|
|
28
|
+
},
|
|
29
|
+
pageViewLocationScopeId: (workspaceId, host) => `${workspaceId}:${host}`,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const { run, rollupByCountry } = await import('./get-page-view-stats.task.js')
|
|
33
|
+
|
|
34
|
+
function cursor (rows) {
|
|
35
|
+
return { toArray: async () => rows }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('rollupByCountry', () => {
|
|
39
|
+
const from = Date.parse('2026-09-01T00:00:00.000Z')
|
|
40
|
+
const to = Date.parse('2026-09-03T23:59:59.999Z')
|
|
41
|
+
|
|
42
|
+
it('sums folded daily country buckets in range and sorts by views', () => {
|
|
43
|
+
const byDay = {
|
|
44
|
+
'2026-08-31': { SE: 9 },
|
|
45
|
+
'2026-09-01': { SE: 1, US: 1 },
|
|
46
|
+
'2026-09-02': { SE: 1, DE: 1 },
|
|
47
|
+
'2026-09-04': { US: 5 },
|
|
48
|
+
}
|
|
49
|
+
expect(rollupByCountry(byDay, { from, to, countryLimit: 250 })).toEqual([
|
|
50
|
+
{ countryCode: 'SE', views: 2 },
|
|
51
|
+
{ countryCode: 'DE', views: 1 },
|
|
52
|
+
{ countryCode: 'US', views: 1 },
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('respects countryLimit', () => {
|
|
57
|
+
const byDay = {
|
|
58
|
+
'2026-09-01': { SE: 2, US: 1 },
|
|
59
|
+
}
|
|
60
|
+
expect(rollupByCountry(byDay, { from, to, countryLimit: 1 })).toEqual([
|
|
61
|
+
{ countryCode: 'SE', views: 2 },
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns [] when byDay is missing', () => {
|
|
66
|
+
expect(rollupByCountry(undefined, { from, to, countryLimit: 10 })).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('get-page-view-stats task', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
aggregate.mockReset()
|
|
73
|
+
findOne.mockReset()
|
|
74
|
+
getProjection.mockReset()
|
|
75
|
+
getProjection.mockResolvedValue({ byDay: { '2026-09-05': { SE: 2, US: 1 } } })
|
|
76
|
+
ofMock.mockImplementation(() => ({
|
|
77
|
+
then (fn) {
|
|
78
|
+
return Promise.resolve(fn({ id: 'ws-1' }))
|
|
79
|
+
},
|
|
80
|
+
}))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('requires host', async () => {
|
|
84
|
+
await expect(run({ payload: { workspaceId: 'ws-1' }, req: {} }))
|
|
85
|
+
.rejects.toMatchObject({ message: 'host is required', status: 400 })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns 404 when the Host is not registered on this workspace', async () => {
|
|
89
|
+
findOne.mockResolvedValue(null)
|
|
90
|
+
await expect(run({
|
|
91
|
+
payload: { workspaceId: 'ws-1', host: 'other.example', from: 1, to: 2 },
|
|
92
|
+
req: {},
|
|
93
|
+
})).rejects.toMatchObject({ status: 404 })
|
|
94
|
+
expect(aggregate).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('returns 404 when the Host belongs to another workspace', async () => {
|
|
98
|
+
findOne.mockResolvedValue({ state: { belongsTo: 'ws-other' } })
|
|
99
|
+
await expect(run({
|
|
100
|
+
payload: { workspaceId: 'ws-1', host: 'other.example', from: 1, to: 2 },
|
|
101
|
+
req: {},
|
|
102
|
+
})).rejects.toMatchObject({ status: 404, message: 'Host not found' })
|
|
103
|
+
expect(aggregate).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('filters unique visitors by host and rolls up byCountry from Host projection', async () => {
|
|
107
|
+
findOne.mockResolvedValue({ state: { belongsTo: 'ws-1' } })
|
|
108
|
+
aggregate
|
|
109
|
+
.mockReturnValueOnce(cursor([{ _id: '2026-09-05', views: 3 }]))
|
|
110
|
+
.mockReturnValueOnce(cursor([{ path: '/', views: 3 }]))
|
|
111
|
+
.mockReturnValueOnce(cursor([{ views: 3 }]))
|
|
112
|
+
.mockReturnValueOnce(cursor([{ visitors: 2 }]))
|
|
113
|
+
|
|
114
|
+
const result = await run({
|
|
115
|
+
payload: {
|
|
116
|
+
workspaceId: 'ws-1',
|
|
117
|
+
host: 'ossy.se',
|
|
118
|
+
from: Date.parse('2026-09-05T00:00:00.000Z'),
|
|
119
|
+
to: Date.parse('2026-09-05T23:59:59.999Z'),
|
|
120
|
+
limit: 10,
|
|
121
|
+
},
|
|
122
|
+
req: {},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(result.host).toBe('ossy.se')
|
|
126
|
+
expect(result.totalViews).toBe(3)
|
|
127
|
+
expect(result.uniqueVisitors).toBe(2)
|
|
128
|
+
expect(result.byCountry).toEqual([
|
|
129
|
+
{ countryCode: 'SE', views: 2 },
|
|
130
|
+
{ countryCode: 'US', views: 1 },
|
|
131
|
+
])
|
|
132
|
+
expect(getProjection).toHaveBeenCalledWith(
|
|
133
|
+
'ws-1:ossy.se',
|
|
134
|
+
'@ossy/analytics/data/page-view-location',
|
|
135
|
+
)
|
|
136
|
+
const match = aggregate.mock.calls[0][0][0].$match
|
|
137
|
+
expect(match['state.content.host']).toBe('ossy.se')
|
|
138
|
+
expect(match['state.created']).toEqual({
|
|
139
|
+
$gte: Date.parse('2026-09-05T00:00:00.000Z'),
|
|
140
|
+
$lte: Date.parse('2026-09-05T23:59:59.999Z'),
|
|
141
|
+
})
|
|
142
|
+
expect(match['state.content.eventAt']).toBeUndefined()
|
|
143
|
+
expect(aggregate.mock.calls[0][0][1].$project.date.$dateToString.date.$toDate)
|
|
144
|
+
.toBe('$state.created')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostname stored on a managed-domain resource (Domains UI uses `content.Domain`).
|
|
3
|
+
*/
|
|
4
|
+
export function hostFromDomainResource (resource) {
|
|
5
|
+
const raw = resource?.content?.Domain || resource?.content?.domain || resource?.name || ''
|
|
6
|
+
const host = String(raw).split(':')[0].trim().toLowerCase()
|
|
7
|
+
if (!host) return ''
|
|
8
|
+
return host.replace(/^www\./, '')
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { hostFromDomainResource } from './host-from-domain-resource.js'
|
|
3
|
+
|
|
4
|
+
describe('hostFromDomainResource', () => {
|
|
5
|
+
it('prefers content.Domain over name', () => {
|
|
6
|
+
expect(hostFromDomainResource({
|
|
7
|
+
name: 'ignored.example',
|
|
8
|
+
content: { Domain: 'www.Ossy.se:443' },
|
|
9
|
+
})).toBe('ossy.se')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('falls back to name', () => {
|
|
13
|
+
expect(hostFromDomainResource({ name: 'customer.com' })).toBe('customer.com')
|
|
14
|
+
})
|
|
15
|
+
})
|
package/src/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { Definition } from './Definition.js'
|
|
2
2
|
export { metadata as GetWorkspaceKpis } from './get-workspace-kpis.action.js'
|
|
3
|
+
export { metadata as CreatePageView } from './create-page-view.action.js'
|
|
4
|
+
export { metadata as GetPageViewStats } from './get-page-view-stats.action.js'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { normalizeHost } from './resolve-page-view-workspace.js'
|
|
2
|
+
import { resolveCountryCodeFromIp } from './resolve-country-from-ip.js'
|
|
3
|
+
|
|
4
|
+
const PAGE_VIEW_SCHEMA_ID = '@ossy/web/schema/page-view'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Projection scope for one Host owned by a workspace (`workspaceId:host`).
|
|
8
|
+
* @param {string | null | undefined} workspaceId
|
|
9
|
+
* @param {string | null | undefined} host
|
|
10
|
+
* @returns {string | null}
|
|
11
|
+
*/
|
|
12
|
+
export function pageViewLocationScopeId (workspaceId, host) {
|
|
13
|
+
const workspace = typeof workspaceId === 'string' ? workspaceId.trim() : ''
|
|
14
|
+
const normalizedHost = normalizeHost(host)
|
|
15
|
+
if (!workspace || !normalizedHost) return null
|
|
16
|
+
return `${workspace}:${normalizedHost}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ADR 0008 projection — Host page-view locations (#769 / Host traffic model).
|
|
21
|
+
*
|
|
22
|
+
* Events store connection `ip` on the eventstore row (ADR 0016). This
|
|
23
|
+
* projection maps IP → `countryCode` via MaxMind GeoLite2-Country and **folds
|
|
24
|
+
* at write** into daily country buckets (`byDay[YYYY-MM-DD][CC] = count`) so
|
|
25
|
+
* the snapshot stays bounded (days × ISO codes), not one row per navigation.
|
|
26
|
+
* Scope is per Host (`workspaceId:host`) so `/analytics` Host picker stays
|
|
27
|
+
* accurate. Day buckets use envelope `created` (not client `eventAt`).
|
|
28
|
+
* Rebuild with a new `.mmdb` still works — events keep the IPs.
|
|
29
|
+
*/
|
|
30
|
+
export class PageViewLocationProjection {
|
|
31
|
+
|
|
32
|
+
static kind = 'projection'
|
|
33
|
+
static ProjectionId = '@ossy/analytics/data/page-view-location'
|
|
34
|
+
|
|
35
|
+
static sources = [
|
|
36
|
+
{ type: PAGE_VIEW_SCHEMA_ID, event: 'Created' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
static scopeFromEvent (event) {
|
|
40
|
+
return pageViewLocationScopeId(
|
|
41
|
+
event.payload?.belongsTo,
|
|
42
|
+
event.payload?.content?.host,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static cacheKeys (scopeId) {
|
|
47
|
+
return [
|
|
48
|
+
`projection:${PageViewLocationProjection.ProjectionId}:${scopeId}`,
|
|
49
|
+
'action:@ossy/analytics/actions/get-page-view-stats',
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static initialState (scopeId) {
|
|
54
|
+
const sep = typeof scopeId === 'string' ? scopeId.indexOf(':') : -1
|
|
55
|
+
const workspaceId = sep > 0 ? scopeId.slice(0, sep) : scopeId
|
|
56
|
+
const host = sep > 0 ? scopeId.slice(sep + 1) : ''
|
|
57
|
+
return { workspaceId, host, byDay: {} }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} event
|
|
62
|
+
* @param {{ workspaceId?: string, host?: string, byDay?: Record<string, Record<string, number>> }} state
|
|
63
|
+
* @returns {Promise<{ workspaceId: string, host: string, byDay: Record<string, Record<string, number>> }>}
|
|
64
|
+
*/
|
|
65
|
+
static async Apply (event, state) {
|
|
66
|
+
const workspaceId = event.payload?.belongsTo ?? state.workspaceId
|
|
67
|
+
const host = normalizeHost(event.payload?.content?.host ?? state.host)
|
|
68
|
+
const byDay = { ...(state.byDay ?? {}) }
|
|
69
|
+
|
|
70
|
+
if (!host) {
|
|
71
|
+
return { workspaceId, host, byDay }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ip = typeof event.ip === 'string' ? event.ip : null
|
|
75
|
+
const countryCode = ip ? await resolveCountryCodeFromIp(ip) : null
|
|
76
|
+
|
|
77
|
+
const code = typeof countryCode === 'string' ? countryCode.trim().toUpperCase() : ''
|
|
78
|
+
if (!/^[A-Z]{2}$/.test(code)) {
|
|
79
|
+
return { workspaceId, host, byDay }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const at = Number(event.created)
|
|
83
|
+
const millis = Number.isFinite(at) && at > 0 ? at : Date.now()
|
|
84
|
+
const day = new Date(millis).toISOString().slice(0, 10)
|
|
85
|
+
const dayBucket = { ...(byDay[day] ?? {}) }
|
|
86
|
+
dayBucket[code] = (dayBucket[code] ?? 0) + 1
|
|
87
|
+
byDay[day] = dayBucket
|
|
88
|
+
|
|
89
|
+
return { workspaceId, host, byDay }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { PageViewLocationProjection as Aggregate }
|
|
94
|
+
export const id = 'analytics/data/page-view-location'
|