@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,103 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const resolveCountryCodeFromIp = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('./resolve-country-from-ip.js', () => ({
|
|
6
|
+
resolveCountryCodeFromIp,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
PageViewLocationProjection,
|
|
11
|
+
pageViewLocationScopeId,
|
|
12
|
+
} = await import('./page-view-location.aggregate.js')
|
|
13
|
+
|
|
14
|
+
describe('PageViewLocationProjection', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resolveCountryCodeFromIp.mockReset()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('scopes by workspaceId:host', () => {
|
|
20
|
+
expect(pageViewLocationScopeId('ws-1', 'www.Ossy.se')).toBe('ws-1:ossy.se')
|
|
21
|
+
expect(PageViewLocationProjection.scopeFromEvent({
|
|
22
|
+
payload: { belongsTo: 'ws-1', content: { host: 'ossy.se' } },
|
|
23
|
+
})).toBe('ws-1:ossy.se')
|
|
24
|
+
expect(PageViewLocationProjection.scopeFromEvent({
|
|
25
|
+
payload: { belongsTo: 'ws-1', content: {} },
|
|
26
|
+
})).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('folds MaxMind country into daily byDay buckets from event.created', async () => {
|
|
30
|
+
resolveCountryCodeFromIp.mockResolvedValue('SE')
|
|
31
|
+
const state = PageViewLocationProjection.initialState('ws-1:ossy.se')
|
|
32
|
+
const next = await PageViewLocationProjection.Apply({
|
|
33
|
+
resourceId: 'pv-1',
|
|
34
|
+
created: Date.parse('2026-09-05T12:00:00.000Z'),
|
|
35
|
+
ip: '203.0.113.10',
|
|
36
|
+
payload: {
|
|
37
|
+
belongsTo: 'ws-1',
|
|
38
|
+
content: { host: 'ossy.se' },
|
|
39
|
+
},
|
|
40
|
+
}, state)
|
|
41
|
+
|
|
42
|
+
expect(resolveCountryCodeFromIp).toHaveBeenCalledWith('203.0.113.10')
|
|
43
|
+
expect(next).toMatchObject({ workspaceId: 'ws-1', host: 'ossy.se' })
|
|
44
|
+
expect(next.byDay).toEqual({ '2026-09-05': { SE: 1 } })
|
|
45
|
+
expect(next).not.toHaveProperty('views')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('increments the same day/country on further views', async () => {
|
|
49
|
+
resolveCountryCodeFromIp.mockResolvedValue('se')
|
|
50
|
+
const mid = await PageViewLocationProjection.Apply({
|
|
51
|
+
resourceId: 'pv-1',
|
|
52
|
+
created: Date.parse('2026-09-05T08:00:00.000Z'),
|
|
53
|
+
ip: '203.0.113.10',
|
|
54
|
+
payload: {
|
|
55
|
+
belongsTo: 'ws-1',
|
|
56
|
+
content: { host: 'ossy.se' },
|
|
57
|
+
},
|
|
58
|
+
}, PageViewLocationProjection.initialState('ws-1:ossy.se'))
|
|
59
|
+
const next = await PageViewLocationProjection.Apply({
|
|
60
|
+
resourceId: 'pv-2',
|
|
61
|
+
created: Date.parse('2026-09-05T18:00:00.000Z'),
|
|
62
|
+
ip: '203.0.113.11',
|
|
63
|
+
payload: {
|
|
64
|
+
belongsTo: 'ws-1',
|
|
65
|
+
content: { host: 'ossy.se' },
|
|
66
|
+
},
|
|
67
|
+
}, mid)
|
|
68
|
+
|
|
69
|
+
expect(next.byDay).toEqual({ '2026-09-05': { SE: 2 } })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('leaves byDay unchanged when lookup misses', async () => {
|
|
73
|
+
resolveCountryCodeFromIp.mockResolvedValue(null)
|
|
74
|
+
const next = await PageViewLocationProjection.Apply({
|
|
75
|
+
resourceId: 'pv-2',
|
|
76
|
+
created: 42,
|
|
77
|
+
ip: '198.51.100.1',
|
|
78
|
+
payload: { belongsTo: 'ws-1', content: { host: 'ossy.se' } },
|
|
79
|
+
}, PageViewLocationProjection.initialState('ws-1:ossy.se'))
|
|
80
|
+
|
|
81
|
+
expect(next.byDay).toEqual({})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('skips MaxMind when host is missing', async () => {
|
|
85
|
+
resolveCountryCodeFromIp.mockResolvedValue('SE')
|
|
86
|
+
const next = await PageViewLocationProjection.Apply({
|
|
87
|
+
resourceId: 'pv-3',
|
|
88
|
+
created: 42,
|
|
89
|
+
ip: '203.0.113.10',
|
|
90
|
+
payload: { belongsTo: 'ws-1', content: {} },
|
|
91
|
+
}, { workspaceId: 'ws-1', host: '', byDay: {} })
|
|
92
|
+
|
|
93
|
+
expect(resolveCountryCodeFromIp).not.toHaveBeenCalled()
|
|
94
|
+
expect(next.byDay).toEqual({})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('invalidates the analytics get-page-view-stats action cache', () => {
|
|
98
|
+
expect(PageViewLocationProjection.cacheKeys('ws-1:ossy.se')).toEqual([
|
|
99
|
+
'projection:@ossy/analytics/data/page-view-location:ws-1:ossy.se',
|
|
100
|
+
'action:@ossy/analytics/actions/get-page-view-stats',
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import maxmind from 'maxmind'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve ISO 3166-1 alpha-2 country from an IP via MaxMind GeoLite2-Country.
|
|
5
|
+
*
|
|
6
|
+
* Used by **PageViewLocationProjection** (not HTTP ingest). Database path:
|
|
7
|
+
* `GEOLITE2_COUNTRY_MMDB` (or `OSSY_GEOLITE2_COUNTRY_MMDB`). When unset /
|
|
8
|
+
* unreadable / lookup miss → null (callers omit `countryCode`).
|
|
9
|
+
* Never reads viewer-supplied country headers (#769).
|
|
10
|
+
*
|
|
11
|
+
* @param {string | null | undefined} ip
|
|
12
|
+
* @returns {Promise<string | null>}
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {import('maxmind').Reader<import('maxmind').CountryResponse> | null | undefined} */
|
|
16
|
+
let reader
|
|
17
|
+
/** @type {string | null | undefined} */
|
|
18
|
+
let openedPath
|
|
19
|
+
/** @type {Promise<import('maxmind').Reader<import('maxmind').CountryResponse> | null> | null} */
|
|
20
|
+
let opening
|
|
21
|
+
|
|
22
|
+
function resolveMmdbPath () {
|
|
23
|
+
const raw = process.env.GEOLITE2_COUNTRY_MMDB
|
|
24
|
+
?? process.env.OSSY_GEOLITE2_COUNTRY_MMDB
|
|
25
|
+
?? ''
|
|
26
|
+
const path = String(raw).trim()
|
|
27
|
+
return path || null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getReader () {
|
|
31
|
+
const path = resolveMmdbPath()
|
|
32
|
+
if (!path) {
|
|
33
|
+
reader = null
|
|
34
|
+
openedPath = null
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
if (reader && openedPath === path) return reader
|
|
38
|
+
if (opening) return opening
|
|
39
|
+
|
|
40
|
+
opening = (async () => {
|
|
41
|
+
try {
|
|
42
|
+
reader = await maxmind.open(path)
|
|
43
|
+
openedPath = path
|
|
44
|
+
return reader
|
|
45
|
+
} catch {
|
|
46
|
+
reader = null
|
|
47
|
+
openedPath = path
|
|
48
|
+
return null
|
|
49
|
+
} finally {
|
|
50
|
+
opening = null
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
|
|
54
|
+
return opening
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test helper — reset cached reader between specs. */
|
|
58
|
+
export function resetGeoLite2CountryReaderForTests () {
|
|
59
|
+
reader = undefined
|
|
60
|
+
openedPath = undefined
|
|
61
|
+
opening = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string | null | undefined} ip
|
|
66
|
+
* @returns {Promise<string | null>}
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveCountryCodeFromIp (ip) {
|
|
69
|
+
if (typeof ip !== 'string' || !ip.trim()) return null
|
|
70
|
+
const lookup = await getReader()
|
|
71
|
+
if (!lookup) return null
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = lookup.get(ip.trim())
|
|
75
|
+
const code = result?.country?.iso_code
|
|
76
|
+
?? result?.registered_country?.iso_code
|
|
77
|
+
if (typeof code !== 'string') return null
|
|
78
|
+
const normalized = code.trim().toUpperCase()
|
|
79
|
+
if (!/^[A-Z]{2}$/.test(normalized)) return null
|
|
80
|
+
if (normalized === 'XX' || normalized === 'ZZ') return null
|
|
81
|
+
return normalized
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const open = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('maxmind', () => ({
|
|
6
|
+
default: { open },
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const { resolveCountryCodeFromIp, resetGeoLite2CountryReaderForTests } = await import('./resolve-country-from-ip.js')
|
|
10
|
+
|
|
11
|
+
describe('resolveCountryCodeFromIp', () => {
|
|
12
|
+
const previousGeo = process.env.GEOLITE2_COUNTRY_MMDB
|
|
13
|
+
const previousOssy = process.env.OSSY_GEOLITE2_COUNTRY_MMDB
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetGeoLite2CountryReaderForTests()
|
|
17
|
+
open.mockReset()
|
|
18
|
+
delete process.env.GEOLITE2_COUNTRY_MMDB
|
|
19
|
+
delete process.env.OSSY_GEOLITE2_COUNTRY_MMDB
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
resetGeoLite2CountryReaderForTests()
|
|
24
|
+
if (previousGeo == null) delete process.env.GEOLITE2_COUNTRY_MMDB
|
|
25
|
+
else process.env.GEOLITE2_COUNTRY_MMDB = previousGeo
|
|
26
|
+
if (previousOssy == null) delete process.env.OSSY_GEOLITE2_COUNTRY_MMDB
|
|
27
|
+
else process.env.OSSY_GEOLITE2_COUNTRY_MMDB = previousOssy
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns null when no mmdb path is configured', async () => {
|
|
31
|
+
expect(await resolveCountryCodeFromIp('8.8.8.8')).toBeNull()
|
|
32
|
+
expect(open).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns ISO country from MaxMind when the reader opens', async () => {
|
|
36
|
+
process.env.GEOLITE2_COUNTRY_MMDB = '/var/lib/GeoLite2-Country.mmdb'
|
|
37
|
+
open.mockResolvedValue({
|
|
38
|
+
get: (ip) => (ip === '8.8.8.8' ? { country: { iso_code: 'us' } } : null),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(await resolveCountryCodeFromIp('8.8.8.8')).toBe('US')
|
|
42
|
+
expect(await resolveCountryCodeFromIp('1.1.1.1')).toBeNull()
|
|
43
|
+
expect(await resolveCountryCodeFromIp('')).toBeNull()
|
|
44
|
+
expect(open).toHaveBeenCalledWith('/var/lib/GeoLite2-Country.mmdb')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null when MaxMind open fails', async () => {
|
|
48
|
+
process.env.GEOLITE2_COUNTRY_MMDB = '/missing.mmdb'
|
|
49
|
+
open.mockRejectedValue(new Error('ENOENT'))
|
|
50
|
+
expect(await resolveCountryCodeFromIp('8.8.8.8')).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const DOMAIN_SCHEMA_ID = '@ossy/domains/schema/managed-domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} raw
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeHost (raw) {
|
|
8
|
+
const host = String(raw || '').split(':')[0].trim().toLowerCase()
|
|
9
|
+
if (!host) return ''
|
|
10
|
+
return host.replace(/^www\./, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isLocalHost (host) {
|
|
14
|
+
return !host
|
|
15
|
+
|| host === 'localhost'
|
|
16
|
+
|| host === '127.0.0.1'
|
|
17
|
+
|| host === '::1'
|
|
18
|
+
|| host.endsWith('.localhost')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hostFromHeaderUrl (value) {
|
|
22
|
+
if (!value) return ''
|
|
23
|
+
try {
|
|
24
|
+
return normalizeHost(new URL(String(value)).hostname)
|
|
25
|
+
} catch {
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Host for public ingest. Prefer Origin (cross-origin POST) then Referer,
|
|
32
|
+
* then req.hostname. Client payload must not choose the Host.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ hostname?: string, headers?: Record<string, string>, get?: Function } | null | undefined} req
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function hostFromRequest (req) {
|
|
38
|
+
const headers = req?.headers || {}
|
|
39
|
+
const get = typeof req?.get === 'function'
|
|
40
|
+
? (name) => req.get(name)
|
|
41
|
+
: (name) => headers[name] || headers[String(name).toLowerCase()]
|
|
42
|
+
|
|
43
|
+
return hostFromHeaderUrl(get('origin'))
|
|
44
|
+
|| hostFromHeaderUrl(get('referer'))
|
|
45
|
+
|| normalizeHost(req?.hostname || get('host'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Workspace that registered this Host, or undefined. No cookie/config fallback.
|
|
50
|
+
*
|
|
51
|
+
* @param {{
|
|
52
|
+
* host: string,
|
|
53
|
+
* findDomain?: (host: string) => Promise<string | undefined>,
|
|
54
|
+
* }} opts
|
|
55
|
+
* @returns {Promise<string | undefined>}
|
|
56
|
+
*/
|
|
57
|
+
export async function resolvePageViewWorkspaceId ({ host, findDomain } = {}) {
|
|
58
|
+
const normalized = normalizeHost(host)
|
|
59
|
+
if (!normalized || !findDomain) return undefined
|
|
60
|
+
return findDomain(normalized)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function domainNameVariants (host) {
|
|
64
|
+
const normalized = normalizeHost(host)
|
|
65
|
+
if (!normalized) return []
|
|
66
|
+
return [normalized, `www.${normalized}`]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {{ findOne: Function }} collection
|
|
71
|
+
* @param {string} host
|
|
72
|
+
* @returns {Promise<string | undefined>}
|
|
73
|
+
*/
|
|
74
|
+
export async function workspaceIdForRegisteredHost (collection, host) {
|
|
75
|
+
const names = domainNameVariants(host)
|
|
76
|
+
if (!collection?.findOne || names.length === 0) return undefined
|
|
77
|
+
const doc = await collection.findOne({
|
|
78
|
+
type: DOMAIN_SCHEMA_ID,
|
|
79
|
+
'state.status': { $ne: 'removed' },
|
|
80
|
+
$or: [
|
|
81
|
+
{ 'state.name': { $in: names } },
|
|
82
|
+
{ 'state.content.Domain': { $in: names } },
|
|
83
|
+
{ 'state.content.domain': { $in: names } },
|
|
84
|
+
],
|
|
85
|
+
})
|
|
86
|
+
return doc?.state?.belongsTo || undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CONTENT_KEYS = ['path', 'section', 'language', 'referrer', 'userAgent', 'visitorId']
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Persist only page-view fields. `host` always comes from the server.
|
|
93
|
+
* When the view happened is resource `created` (envelope), not content.
|
|
94
|
+
* Connection `ip` is stamped on the eventstore row at append (ADR 0016) — never from the client.
|
|
95
|
+
*
|
|
96
|
+
* @param {Record<string, unknown>} payload
|
|
97
|
+
* @param {{ userAgent?: string, path?: string, host?: string }} fallbacks
|
|
98
|
+
*/
|
|
99
|
+
export function pageViewContentFromPayload (payload, fallbacks = {}) {
|
|
100
|
+
const src = payload && typeof payload === 'object' ? payload : {}
|
|
101
|
+
const content = {}
|
|
102
|
+
for (const key of CONTENT_KEYS) {
|
|
103
|
+
const value = src[key]
|
|
104
|
+
if (value != null && value !== '') content[key] = value
|
|
105
|
+
}
|
|
106
|
+
if (content.userAgent == null && fallbacks.userAgent) content.userAgent = fallbacks.userAgent
|
|
107
|
+
content.path = content.path || fallbacks.path || '/'
|
|
108
|
+
if (fallbacks.host) content.host = fallbacks.host
|
|
109
|
+
return content
|
|
110
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
hostFromRequest,
|
|
4
|
+
isLocalHost,
|
|
5
|
+
normalizeHost,
|
|
6
|
+
pageViewContentFromPayload,
|
|
7
|
+
resolvePageViewWorkspaceId,
|
|
8
|
+
workspaceIdForRegisteredHost,
|
|
9
|
+
} from './resolve-page-view-workspace.js'
|
|
10
|
+
|
|
11
|
+
describe('normalizeHost', () => {
|
|
12
|
+
it('strips port and www', () => {
|
|
13
|
+
expect(normalizeHost('www.Ossy.se:443')).toBe('ossy.se')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('hostFromRequest', () => {
|
|
18
|
+
it('prefers Origin over hostname so cross-origin ingest uses the page Host', () => {
|
|
19
|
+
expect(hostFromRequest({
|
|
20
|
+
hostname: 'ossy.se',
|
|
21
|
+
headers: { origin: 'https://www.customer.com', host: 'ossy.se' },
|
|
22
|
+
})).toBe('customer.com')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('falls back to hostname when Origin is missing', () => {
|
|
26
|
+
expect(hostFromRequest({ hostname: 'www.ossy.se:443' })).toBe('ossy.se')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('resolvePageViewWorkspaceId', () => {
|
|
31
|
+
it('looks up the Host and ignores cookies', async () => {
|
|
32
|
+
const findDomain = jest.fn(async () => 'ws-ossy')
|
|
33
|
+
const id = await resolvePageViewWorkspaceId({ host: 'ossy.se', findDomain })
|
|
34
|
+
expect(id).toBe('ws-ossy')
|
|
35
|
+
expect(findDomain).toHaveBeenCalledWith('ossy.se')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('looks up localhost like any other Host', async () => {
|
|
39
|
+
const findDomain = jest.fn(async () => 'ws-local')
|
|
40
|
+
expect(await resolvePageViewWorkspaceId({
|
|
41
|
+
host: 'localhost',
|
|
42
|
+
findDomain,
|
|
43
|
+
})).toBe('ws-local')
|
|
44
|
+
expect(findDomain).toHaveBeenCalledWith('localhost')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns undefined for unknown Hosts', async () => {
|
|
48
|
+
expect(await resolvePageViewWorkspaceId({
|
|
49
|
+
host: 'unknown.example',
|
|
50
|
+
findDomain: async () => undefined,
|
|
51
|
+
})).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('workspaceIdForRegisteredHost', () => {
|
|
56
|
+
it('matches apex or www name', async () => {
|
|
57
|
+
const findOne = jest.fn(async () => ({ state: { belongsTo: 'ws-1' } }))
|
|
58
|
+
await expect(workspaceIdForRegisteredHost({ findOne }, 'ossy.se')).resolves.toBe('ws-1')
|
|
59
|
+
expect(findOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
60
|
+
$or: expect.arrayContaining([
|
|
61
|
+
{ 'state.name': { $in: ['ossy.se', 'www.ossy.se'] } },
|
|
62
|
+
{ 'state.content.Domain': { $in: ['ossy.se', 'www.ossy.se'] } },
|
|
63
|
+
]),
|
|
64
|
+
}))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('matches a Domains UI document by content.Domain', async () => {
|
|
68
|
+
const findOne = jest.fn(async () => ({ state: { belongsTo: 'ws-1' } }))
|
|
69
|
+
await expect(workspaceIdForRegisteredHost({ findOne }, 'localhost')).resolves.toBe('ws-1')
|
|
70
|
+
expect(findOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
71
|
+
$or: expect.arrayContaining([
|
|
72
|
+
{ 'state.content.Domain': { $in: ['localhost', 'www.localhost'] } },
|
|
73
|
+
]),
|
|
74
|
+
}))
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('pageViewContentFromPayload', () => {
|
|
79
|
+
it('keeps page-view fields, sets server host, drops workspaceId / ip / eventAt', () => {
|
|
80
|
+
expect(pageViewContentFromPayload({
|
|
81
|
+
path: '/pricing',
|
|
82
|
+
visitorId: 'v1',
|
|
83
|
+
workspaceId: 'ws-spoof',
|
|
84
|
+
ip: '1.2.3.4',
|
|
85
|
+
host: 'evil.example',
|
|
86
|
+
eventAt: 100,
|
|
87
|
+
}, { userAgent: 'Mozilla', path: '/', host: 'ossy.se' })).toEqual({
|
|
88
|
+
path: '/pricing',
|
|
89
|
+
visitorId: 'v1',
|
|
90
|
+
userAgent: 'Mozilla',
|
|
91
|
+
host: 'ossy.se',
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('isLocalHost', () => {
|
|
97
|
+
it('treats loopback as local', () => {
|
|
98
|
+
expect(isLocalHost('localhost')).toBe(true)
|
|
99
|
+
expect(isLocalHost('ossy.se')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
package/src/sv.translations.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"analytics/home.documentTitle": "Analys",
|
|
3
3
|
"analytics.home.title": "Analys",
|
|
4
|
-
"analytics.home.description": "
|
|
4
|
+
"analytics.home.description": "Unika besökare och sidvisningar för en registrerad domän.",
|
|
5
5
|
"analytics.home.overview": "Översikt",
|
|
6
6
|
"analytics.home.traffic": "Trafik",
|
|
7
7
|
"analytics.home.loading": "Laddar analys...",
|
|
8
8
|
"analytics.home.error": "Kunde inte ladda analysdata.",
|
|
9
|
-
"analytics.home.
|
|
10
|
-
"analytics.home.
|
|
11
|
-
"analytics.home.
|
|
12
|
-
"analytics.home.
|
|
9
|
+
"analytics.home.empty": "Registrera en domän för att se besökare och sidvisningar för den.",
|
|
10
|
+
"analytics.home.emptyEnableDomains": "Aktivera Domäner för workspace, registrera sedan en domän för att se besökare.",
|
|
11
|
+
"analytics.home.addDomain": "Lägg till en domän",
|
|
12
|
+
"analytics.home.enableDomains": "Aktivera domäner",
|
|
13
|
+
"analytics.home.hostLabel": "Domän",
|
|
14
|
+
"analytics.home.uniqueVisitors": "Besökare (30 dagar)",
|
|
15
|
+
"analytics.home.totalPageViews": "Sidvisningar (30 dagar)",
|
|
13
16
|
"analytics.home.trackedPages": "Spårade sidor (30 dagar)",
|
|
14
17
|
"analytics.home.daysWithTraffic": "Dagar med trafik",
|
|
15
18
|
"analytics.home.topPaths": "Topp-sökvägar",
|
|
@@ -17,18 +20,26 @@
|
|
|
17
20
|
"analytics.home.unknownPath": "(okänd sökväg)",
|
|
18
21
|
"analytics.home.dailyPageViews": "Dagliga sidvisningar",
|
|
19
22
|
"analytics.home.noDailyData": "Ingen daglig data ännu.",
|
|
20
|
-
"analytics.home.
|
|
21
|
-
"analytics.home.
|
|
23
|
+
"analytics.home.locations": "Besökarnas platser",
|
|
24
|
+
"analytics.home.noLocationData": "Ingen platsdata ännu. Land härleds från besökarens IP (GeoLite2) när det finns — råa IP-adresser visas inte.",
|
|
25
|
+
"analytics.home.cover.title": "Se vilka som besöker din sajt",
|
|
26
|
+
"analytics.home.cover.text": "Unika besökare och sidvisningar för varje domän du registrerar — även de som aldrig loggar in.",
|
|
22
27
|
"analytics.home.cover.ctaPrimary": "Kom igång gratis",
|
|
23
|
-
"analytics.home.features.
|
|
24
|
-
"analytics.home.features.
|
|
25
|
-
"analytics.home.features.
|
|
26
|
-
"analytics.home.features.
|
|
28
|
+
"analytics.home.features.visitors.title": "Besökare",
|
|
29
|
+
"analytics.home.features.visitors.text": "Räkna alla på en registrerad domän — anonyma besökare och inloggade medlemmar.",
|
|
30
|
+
"analytics.home.features.paths.title": "Topp-sökvägar",
|
|
31
|
+
"analytics.home.features.paths.text": "Se vilka sidor folk öppnar mest under de senaste 30 dagarna.",
|
|
27
32
|
"analytics.home.features.traffic.title": "Trafik",
|
|
28
|
-
"analytics.home.features.traffic.text": "
|
|
33
|
+
"analytics.home.features.traffic.text": "Välj en domän i taget. Varje domän har egna besökar-, sidvisnings- och plats-siffror.",
|
|
29
34
|
"analytics.sales.overview": "Översikt",
|
|
30
35
|
"analytics.sales.features": "Funktioner",
|
|
31
36
|
"analytics.sales.enable": "Aktivera analys",
|
|
32
|
-
"analytics.sales.enableDescription": "Aktivera analys för
|
|
33
|
-
"analytics.sales.enableError": "Kunde inte aktivera analys. Försök igen eller kontakta support."
|
|
37
|
+
"analytics.sales.enableDescription": "Aktivera analys för att se besökare och sidvisningar för domäner du registrerar.",
|
|
38
|
+
"analytics.sales.enableError": "Kunde inte aktivera analys. Försök igen eller kontakta support.",
|
|
39
|
+
"@ossy/analytics/actions/create-page-view.label": "Skapa sidvisning",
|
|
40
|
+
"@ossy/analytics/actions/create-page-view.description": "Registrera en sidvisning för en registrerad domän",
|
|
41
|
+
"@ossy/analytics/actions/get-page-view-stats.label": "Hämta sidvisningsstatistik",
|
|
42
|
+
"@ossy/analytics/actions/get-page-view-stats.description": "Hämta besökar- och sidvisningsstatistik för en registrerad domän",
|
|
43
|
+
"@ossy/analytics/actions/get-workspace-kpis.label": "Hämta workspace-KPIer",
|
|
44
|
+
"@ossy/analytics/actions/get-workspace-kpis.description": "Antal medlemmar och resurser för aktuellt workspace"
|
|
34
45
|
}
|
package/src/usePageViewStats.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { GetPageViewStats } from '
|
|
2
|
+
import { metadata as GetPageViewStats } from './get-page-view-stats.action.js'
|
|
3
3
|
import { useSdk } from '@ossy/sdk-react'
|
|
4
4
|
|
|
5
|
-
export function usePageViewStats({ workspaceId, from, to, limit = 10 }) {
|
|
5
|
+
export function usePageViewStats({ workspaceId, host, from, to, limit = 10 }) {
|
|
6
6
|
const sdk = useSdk()
|
|
7
7
|
const invokeRef = useRef(sdk.invoke)
|
|
8
8
|
invokeRef.current = sdk.invoke
|
|
@@ -10,23 +10,28 @@ export function usePageViewStats({ workspaceId, from, to, limit = 10 }) {
|
|
|
10
10
|
const [status, setStatus] = useState('idle')
|
|
11
11
|
const [data, setData] = useState({
|
|
12
12
|
totalViews: 0,
|
|
13
|
+
uniqueVisitors: 0,
|
|
13
14
|
dailyViews: [],
|
|
14
15
|
topPaths: [],
|
|
16
|
+
byCountry: [],
|
|
15
17
|
})
|
|
16
18
|
|
|
17
19
|
useEffect(() => {
|
|
18
|
-
if (!workspaceId) return undefined
|
|
20
|
+
if (!workspaceId || !host) return undefined
|
|
19
21
|
|
|
20
22
|
let cancelled = false
|
|
21
|
-
setStatus(
|
|
23
|
+
setStatus('loading')
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
// countryLimit stays at the task default (~250 ISO codes); do not reuse top-paths `limit`.
|
|
26
|
+
invokeRef.current(GetPageViewStats, { host, from, to, limit })
|
|
24
27
|
.then((json) => {
|
|
25
28
|
if (cancelled) return
|
|
26
29
|
setData({
|
|
27
30
|
totalViews: json?.totalViews || 0,
|
|
31
|
+
uniqueVisitors: json?.uniqueVisitors || 0,
|
|
28
32
|
dailyViews: json?.dailyViews || [],
|
|
29
33
|
topPaths: json?.topPaths || [],
|
|
34
|
+
byCountry: json?.byCountry || [],
|
|
30
35
|
})
|
|
31
36
|
setStatus('success')
|
|
32
37
|
})
|
|
@@ -38,7 +43,7 @@ export function usePageViewStats({ workspaceId, from, to, limit = 10 }) {
|
|
|
38
43
|
return () => {
|
|
39
44
|
cancelled = true
|
|
40
45
|
}
|
|
41
|
-
}, [workspaceId, from, to, limit])
|
|
46
|
+
}, [workspaceId, host, from, to, limit])
|
|
42
47
|
|
|
43
48
|
return {
|
|
44
49
|
status,
|
|
@@ -8,18 +8,18 @@ export const metadata = {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* After enabling analytics, wait until
|
|
12
|
-
*
|
|
11
|
+
* After enabling analytics, wait until the product home settles.
|
|
12
|
+
* New workspaces have no domain, so the overview is empty — not traffic-ready.
|
|
13
13
|
*/
|
|
14
14
|
export default {
|
|
15
15
|
title: 'View analytics overview',
|
|
16
16
|
description:
|
|
17
|
-
'Signed-in user enables analytics and sees
|
|
17
|
+
'Signed-in user enables analytics and sees an empty Host state or traffic for a registered domain',
|
|
18
18
|
steps: [
|
|
19
19
|
...enableAnalyticsFlow.steps,
|
|
20
20
|
{
|
|
21
21
|
result: {
|
|
22
|
-
selector: '[data-analytics-overview-status="ready"]',
|
|
22
|
+
selector: '[data-analytics-overview-status="empty"], [data-analytics-overview-status="ready"]',
|
|
23
23
|
timeout: 20000,
|
|
24
24
|
},
|
|
25
25
|
},
|
package/src/useWorkspaceKpis.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { useSdk } from '@ossy/sdk-react'
|
|
3
|
-
import { metadata as GetWorkspaceKpis } from './get-workspace-kpis.action.js'
|
|
4
|
-
|
|
5
|
-
export function useWorkspaceKpis({ workspaceId }) {
|
|
6
|
-
const sdk = useSdk()
|
|
7
|
-
const invokeRef = useRef(sdk.invoke)
|
|
8
|
-
invokeRef.current = sdk.invoke
|
|
9
|
-
|
|
10
|
-
const [status, setStatus] = useState('idle')
|
|
11
|
-
const [data, setData] = useState({
|
|
12
|
-
members: 0,
|
|
13
|
-
resources: 0,
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
if (!workspaceId) return undefined
|
|
18
|
-
|
|
19
|
-
let cancelled = false
|
|
20
|
-
setStatus((current) => (current === 'success' ? current : 'loading'))
|
|
21
|
-
|
|
22
|
-
invokeRef.current(GetWorkspaceKpis, {})
|
|
23
|
-
.then((json) => {
|
|
24
|
-
if (cancelled) return
|
|
25
|
-
setData({
|
|
26
|
-
members: json?.members || 0,
|
|
27
|
-
resources: json?.resources || 0,
|
|
28
|
-
})
|
|
29
|
-
setStatus('success')
|
|
30
|
-
})
|
|
31
|
-
.catch(() => {
|
|
32
|
-
if (cancelled) return
|
|
33
|
-
setStatus('error')
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
return () => {
|
|
37
|
-
cancelled = true
|
|
38
|
-
}
|
|
39
|
-
}, [workspaceId])
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
status,
|
|
43
|
-
...data,
|
|
44
|
-
}
|
|
45
|
-
}
|