@ossy/analytics 3.11.0 → 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 CHANGED
@@ -1,30 +1,33 @@
1
1
  # @ossy/analytics
2
2
 
3
- Analytics feature package for the Ossy platform. Shows **workspace-scoped** traffic and insight KPIs.
3
+ Host-traffic analytics. See **[SPEC.md](./SPEC.md)**.
4
4
 
5
- Platform-wide (ops) insights belong in a future back-office surfacesee [ADR 0014](../../docs/adr/0014-system-insights-in-back-office.md).
5
+ Platform-wide (ops) insights belong in a back-office Mongo dashboard — [ADR 0014](../../docs/adr/0014-system-insights-in-back-office.md).
6
6
 
7
7
  ## Pages
8
8
 
9
9
  | Page | Path | Audience |
10
10
  |------|------|----------|
11
- | `analytics/home` | `/analytics` · `/analys` | Signed-in + entitled → product KPIs. Otherwise the public sales page (no KPI loads). |
11
+ | `analytics/home` | `/analytics` · `/analys` | Signed-in + entitled → traffic for a **registered Host**. No domain → empty state. Otherwise the public sales page (no stats loads). |
12
+
13
+ Workspace member/resource counts are **not** this package — they belong on workspace settings.
12
14
 
13
15
  ## Actions
14
16
 
15
17
  | Action | Access | Notes |
16
18
  |--------|--------|-------|
17
- | `@ossy/analytics/actions/get-workspace-kpis` | `workspace` | Member count + non-removed resource count for the current workspace |
18
- | `@ossy/resources/actions/get-page-view-stats` | `workspace` | Page-view aggregates (implemented in `@ossy/resources`) |
19
+ | `@ossy/analytics/actions/create-page-view` | `public` | Ingest. Server resolves Host (`hostname` / `Origin`) via managed-domain registry. Unknown Host → 200 no-op (`recorded: false`). Connection `ip` is stamped on the eventstore row (ADR 0016). |
20
+ | `@ossy/analytics/actions/get-page-view-stats` | `workspace` | Aggregates for a Host this workspace registered (`host` required). Window is resource `created`. Returns `uniqueVisitors`, paths, and `byCountry`. |
21
+ | `@ossy/analytics/actions/get-workspace-kpis` | `workspace` | Member + resource counts — UI on `/settings`; not shown on `/analytics`. |
22
+
23
+ ## Locations (#769)
19
24
 
20
- ## Client usage
25
+ **Event (source of truth):** after Host allowlist, the page-view **eventstore row** carries the trusted connection IP (`ip`, ADR 0016). Client body `ip` / `countryCode` and viewer geo headers are ignored. Unknown Hosts never write an event.
21
26
 
22
- ```js
23
- import { GetWorkspaceKpis } from '@ossy/analytics'
27
+ **Read model:** `PageViewLocationProjection` (`@ossy/analytics/data/page-view-location`) maps `event.ip` → ISO `countryCode` via MaxMind GeoLite2-Country (`GEOLITE2_COUNTRY_MMDB`) and folds counts into daily country buckets (`byDay[YYYY-MM-DD][CC]`) scoped per Host (`workspaceId:host`). Day keys use envelope `created`. Rebuilding with a new `.mmdb` refreshes historical countries while events keep raw IPs.
24
28
 
25
- await sdk.invoke(GetWorkspaceKpis, {})
26
- ```
29
+ **UI:** location / `byCountry` only — never show IP. Host-scoped (pick a domain on `/analytics`).
27
30
 
28
31
  ## Preview
29
32
 
30
- Enable Analytics in workspace entitlements (or `devEntitlements` in `@ossy/app-test`), then open `/analytics`.
33
+ Enable Analytics, register a domain on the workspace, then open `/analytics` and select that Host.
package/SPEC.md ADDED
@@ -0,0 +1,177 @@
1
+ # Analytics — Feature Specification
2
+
3
+ > Package: `@ossy/analytics` · Status: **beta** (Host-traffic model)
4
+
5
+ ---
6
+
7
+ ## Problem
8
+
9
+ Operators need to know **how many people visited a Host** (e.g. `ossy.se`) — unique visitors and page views. That includes anonymous traffic and signed-in members. Attribution must follow the **Host**, not the visitor’s selected workspace.
10
+
11
+ **Core user story:** "I register a domain on my workspace. Visitors to that Host are counted (signed in or not). I open `/analytics`, pick that domain, and see unique visitors and page views."
12
+
13
+ **Non-goal:** Attributing views to the visitor’s selected workspace (cookie / `appWorkspaceId` / client `workspaceId`). A signed-in member of another workspace visiting `ossy.se` counts on Ossy’s Host.
14
+
15
+ ---
16
+
17
+ ## Tenancy
18
+
19
+ | Layer | Meaning |
20
+ |---|---|
21
+ | Host | A registered managed domain (`@ossy/domains/schema/managed-domain`; hostname on `state.name` and/or `content.Domain`) |
22
+ | Workspace | Owns zero or more Hosts (`belongsTo` on the domain resource) |
23
+ | Write | One public ingest. Attribution is **Host → workspace**. Never client `workspaceId`. |
24
+ | Read | Workspace members see stats **for a Host they registered**, not a blob of all workspace page views |
25
+
26
+ The domain is registered **on the workspace** (`@ossy/apps/actions/register-domain` / Domains UI). It is **not** a parameter the ingest action trusts from the client.
27
+
28
+ ---
29
+
30
+ ## One write path
31
+
32
+ There is a single ingest: `@ossy/analytics/actions/create-page-view` (`access: 'public'`).
33
+
34
+ ```
35
+ browser → POST /actions { action: create-page-view, payload: { path, visitorId, … } }
36
+ → resolve Host
37
+ → lookup managed domain
38
+ → registered: page-view resource (belongsTo = that workspace, content.host = Host)
39
+ → unknown / local: 200 `{ ok: true, recorded: false }` (no write)
40
+ ```
41
+
42
+ No second event type, no parallel store, no member-session ingest, no baked `config.workspaceId` fallback.
43
+
44
+ ### Resolve Host
45
+
46
+ | Request | Host |
47
+ |---|---|
48
+ | Same-origin (page on `ossy.se` posts to `ossy.se`) | `req.hostname` |
49
+ | Cross-origin (page on `customer.com` posts to Ossy `/actions`) | Browser `Origin` hostname (fallback `Referer`) |
50
+
51
+ Strip port and a leading `www.`. `localhost` / loopback are Hosts **if registered** (needed for local app-test). Unregistered local Hosts are the same 200 no-op as any unknown Host.
52
+
53
+ ### Lookup
54
+
55
+ Managed-domain with hostname on `state.name` **or** `state.content.Domain` / `state.content.domain` (apex or `www.`), not removed. `belongsTo` is the workspace that registered it. Document display name may differ from the Domain field — both must resolve the same Host.
56
+
57
+ Unknown or local unregistered Host → **200 no-op** (`recorded: false`). Do **not** return 400/404 — that would enumerate the registry. Do not fall back to `req.workspaceId`, cookies, or app config.
58
+
59
+ ### Persist
60
+
61
+ Page views are **resources**. When the view happened is resource `created` (envelope, unix ms, stamped at ingest). Do not duplicate that as `content.eventAt`. Stats window and daily buckets use `state.created`.
62
+
63
+ Content **must** include `host` (the resolved hostname). Also: `path`, `visitorId`, optional `section` / `language` / `referrer` / `userAgent`.
64
+
65
+ After Host allowlist, persist a page-view resource. Trusted connection `ip` is stamped on the **eventstore row** (ADR 0016), never `payload.ip`. Do **not** GeoIP at ingest — `countryCode` is a projection field. Do **not** list `ip` / `countryCode` on the page-view schema.
66
+
67
+ Drop client `workspaceId`, `ip`, `countryCode`, client `host`, client `eventAt`, and any other extras. `createdBy` is `req.userId` or `public`. Resource `name` is the path.
68
+
69
+ `visitorId` is a stable anonymous id from the client (localStorage). Unique visitors = distinct `visitorId` per Host in the window. Signed-in users keep the same visitor id in that browser.
70
+
71
+ Location stays `/@ossy/analytics/page-views/`. `belongsTo` is the registry workspace so ACL/stats stay tenant-scoped.
72
+
73
+ ### Tracker
74
+
75
+ `PageViewTracker` lives in the app shell and invokes create-page-view on every client navigation (no `GetWorkspace`). Signed-in product routes on a registered Host count as that Host’s traffic. Failures are silent. A site that is not a registered Host is a no-op (`recorded: false`).
76
+
77
+ External / non-platform sites use the same action: POST `/actions` from their origin. Attribution is `Origin`, which must match a managed domain.
78
+
79
+ ---
80
+
81
+ ## Read path
82
+
83
+ `@ossy/analytics/actions/get-page-view-stats` remains `access: 'workspace'`.
84
+
85
+ **Required:** `host` (or `domain`) in the payload. Stats match `belongsTo` = current workspace **and** `content.host` = that Host, filtered by resource `created` in the window. The Host must be registered on this workspace; otherwise **404** (same as unknown — do not distinguish “exists on another tenant”).
86
+
87
+ **Time window:** filter and bucket on resource envelope `state.created` (server clock), not client `content.eventAt`.
88
+
89
+ **Returns:** `host`, `from`, `to`, `uniqueVisitors`, `totalViews`, `dailyViews[]`, `topPaths[]`, `byCountry[]` (default last 30 days). `byCountry` comes from `PageViewLocationProjection` (MaxMind), not from write-event `countryCode`.
90
+
91
+ `/analytics` (`analytics/home`):
92
+
93
+ 1. Signed out or not entitled → sales page (unchanged).
94
+ 2. Entitled, **no** registered domains:
95
+ - Domains package **not** entitled → CTA to enable Domains (`packages/detail` for `domains`). Do not open generic create under `/@ossy/domains/`.
96
+ - Domains entitled → empty state: add a domain (link to Domains home). Do not load member/resource KPIs. Do not query stats. `data-analytics-overview-status="empty"`.
97
+ 3. Entitled, one or more domains → **must pick a Host** (query/select). Then show visitors / views / top paths / daily / locations for that Host.
98
+
99
+ Analytics **requires a domain** in the product sense: no Host, no traffic UI. Enabling the package without a domain is allowed; the home page tells you to register one.
100
+
101
+ ---
102
+
103
+ ## Multiple domains
104
+
105
+ One workspace may register `example.com` and `blog.example.com`. Same write. Events differ by `content.host`; both `belongsTo` that workspace.
106
+
107
+ The dashboard does not sum Hosts together. Pick one. (Later: optional roll-up is a new read, not a second write.)
108
+
109
+ ---
110
+
111
+ ## Workspace KPIs (not analytics)
112
+
113
+ Member count and resource count are workspace facts, not Host traffic.
114
+
115
+ | Today | Target |
116
+ |---|---|
117
+ | `@ossy/analytics/actions/get-workspace-kpis` on `/analytics` | Same action on **workspace settings** (`workspace/settings`, `/settings`) |
118
+
119
+ `@ossy/analytics` does not show members/resources. Sales copy for analytics is about site visitors, not seat counts.
120
+
121
+ ---
122
+
123
+ ## Entitlements
124
+
125
+ | Package | Role |
126
+ |---|---|
127
+ | `@ossy/domains` | Register Hosts (`/@ossy/domains/`). Analytics must not create documents in that namespace when Domains is off. |
128
+ | `@ossy/analytics` | View Host traffic (`/@ossy/analytics/page-views/`) |
129
+
130
+ Ingest does **not** require analytics entitlement — a registered Host is enough to accept views. **Reading** stats requires analytics entitled + membership on the workspace that owns the Host.
131
+
132
+ Do not add a second ingest gated on entitlement.
133
+
134
+ ---
135
+
136
+ ## Locations (#769)
137
+
138
+ | Layer | Behaviour |
139
+ |---|---|
140
+ | Ingest | After Host allowlist only: append the page-view event. Connection `ip` is stamped on the eventstore row (ADR 0016). No MaxMind at write. Unknown Host → no write. |
141
+ | Projection | `PageViewLocationProjection` (`@ossy/analytics/data/page-view-location`), scope `workspaceId:host`, folds `byDay[YYYY-MM-DD][CC]` from MaxMind GeoLite2-Country (`GEOLITE2_COUNTRY_MMDB`). Day from envelope `created`. |
142
+ | Stats | `byCountry` rollup from the Host projection for the window (`countryLimit` separate from top-paths `limit`). |
143
+ | UI | Location tiles / ranked list only — never show raw IP. |
144
+
145
+ ## Out of scope
146
+
147
+ - Interactive world map (Phase 0 is ISO tiles + ranked list)
148
+ - Platform-wide visitor totals in the product (ADR 0014 / 0015 — ops Mongo/Grafana)
149
+ - `workspaceId` in website `src/config.js` as a substitute for Host registration
150
+ - Bot filtering, sampling, or rate limits (follow-up)
151
+ - Summing multiple Hosts on one chart
152
+
153
+ ---
154
+
155
+ ## Implementation notes (current vs this spec)
156
+
157
+ |#797 / previous | This spec |
158
+ |---|---|
159
+ | Host lookup, then cookie `workspaceId`, then `config.workspaceId` | Host lookup only; else 200 no-op |
160
+ | Unknown Host → 400 | Unknown Host → 200 `{ recorded: false }` (no registry leak) |
161
+ | Stats unfiltered by `host`; window on `content.eventAt` | Stats require `host`; window and daily buckets on resource `created` |
162
+ | `/analytics` shows members + resources + all workspace views | Host picker + traffic only; KPIs on `/settings` |
163
+ | Page-view schema has `eventAt`, no `host` | Required `host`; no `eventAt` — envelope `created` is the clock |
164
+ | Signed-in app navigations excluded (or attributed to the session workspace) | Same Host ingest for anonymous and signed-in |
165
+
166
+ ---
167
+
168
+ ## DoD
169
+
170
+ - [ ] `create-page-view` public; Host from hostname/`Origin`; unknown/local Host → 200 no-op; no cookie/config fallback
171
+ - [ ] Page-view events store `host`; client cannot set `belongsTo` or `eventAt`; when = resource `created`
172
+ - [ ] `get-page-view-stats` requires a Host registered on the current workspace; filters by `state.created`
173
+ - [ ] `/analytics` empty without a domain; with domains, picker + unique visitors / views for the selected Host
174
+ - [ ] Member/resource counts on workspace settings, not analytics home
175
+ - [ ] Cross-origin POST with `Origin: https://{registered-host}` attributes to that Host’s workspace
176
+ - [ ] Signed-in member on another workspace visiting `ossy.se` counts on Ossy’s Host, not the member’s workspace
177
+ - [ ] Registered Host ingest stamps `event.ip`; projection derives `countryCode`; `/analytics` shows `byCountry` for the selected Host (no IP in UI)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/analytics",
3
- "description": "Analytics module — workspace insight KPIs",
4
- "version": "3.11.0",
3
+ "description": "Analytics module — Host visitor traffic for registered domains",
4
+ "version": "3.12.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -22,16 +22,21 @@
22
22
  },
23
23
  "files": [
24
24
  "/src",
25
- "README.md"
25
+ "README.md",
26
+ "SPEC.md"
26
27
  ],
27
28
  "dependencies": {
28
- "@ossy/event-store": "^3.11.0",
29
- "@ossy/workspaces": "^3.11.0"
29
+ "@ossy/event-store": "^3.12.0",
30
+ "@ossy/resources": "^3.12.0",
31
+ "@ossy/workspaces": "^3.12.0",
32
+ "maxmind": "^5.0.7",
33
+ "nanoid": "^5.1.11"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "@ossy/app": "*",
33
37
  "@ossy/authentication": "*",
34
38
  "@ossy/design-system": "*",
39
+ "@ossy/domains": "*",
35
40
  "@ossy/resources": "*",
36
41
  "@ossy/router-react": "*",
37
42
  "@ossy/sdk-react": "*",
@@ -42,5 +47,5 @@
42
47
  "@jest/globals": "^30.2.0",
43
48
  "jest": "^30.2.0"
44
49
  },
45
- "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
50
+ "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
46
51
  }
@@ -1,7 +1,14 @@
1
1
  import React, { useMemo } from 'react'
2
- import { Text, View, Page, useLocale } from '@ossy/design-system'
2
+ import { ListResources } from '@ossy/resources'
3
+ import { location as domainsLocation, DomainsSchema } from '@ossy/domains'
4
+ import { AsyncStatus, useSdk } from '@ossy/sdk-react'
5
+ import { useRouter } from '@ossy/router-react'
6
+ import { Text, View, Page, Button, Select, useLocale } from '@ossy/design-system'
7
+ import { isServiceEntitled } from '@ossy/workspaces/entitlements'
3
8
  import { usePageViewStats } from './usePageViewStats.js'
4
- import { useWorkspaceKpis } from './useWorkspaceKpis.js'
9
+ import { hostFromDomainResource } from './host-from-domain-resource.js'
10
+
11
+ const DOMAINS_SERVICE = '@ossy/domains'
5
12
 
6
13
  function SectionPanel ({ title, children }) {
7
14
  return (
@@ -38,70 +45,114 @@ function SummaryCard ({ label, value }) {
38
45
  )
39
46
  }
40
47
 
48
+ function overviewStatusFrom ({ listStatus, hostCount, statsStatus }) {
49
+ if (listStatus === AsyncStatus.Error || listStatus === AsyncStatus.AuthenticationError) return 'error'
50
+ if (listStatus === AsyncStatus.Loading || listStatus === AsyncStatus.NotInitialized) return 'loading'
51
+ if (hostCount === 0) return 'empty'
52
+ if (statsStatus === 'error') return 'error'
53
+ if (statsStatus === 'success') return 'ready'
54
+ return 'loading'
55
+ }
56
+
41
57
  /**
42
- * Workspace analytics product UI. Only mount when the caller is authenticated
43
- * so KPI / page-view actions do not run for anonymous sessions.
44
- * Workspace comes from the page (shell snapshot) — do not re-read GetWorkspace.
58
+ * Host-traffic analytics. Only mount when the caller is authenticated
59
+ * and entitled. Workspace comes from the page (shell snapshot).
45
60
  */
46
61
  export default function AnalyticsProductHome ({ workspace }) {
47
62
  const { t } = useLocale()
63
+ const router = useRouter()
64
+ const sdk = useSdk()
48
65
  const { from, to } = useMemo(() => {
49
66
  const now = Date.now()
50
67
  return { from: now - 30 * 24 * 60 * 60 * 1000, to: now }
51
68
  }, [])
52
69
 
70
+ const { status: listStatus, data: resources = [] } = sdk.read(ListResources, { location: domainsLocation })
71
+ const hosts = useMemo(() => {
72
+ const seen = new Set()
73
+ for (const resource of resources || []) {
74
+ if (resource?.type !== DomainsSchema.managedDomain) continue
75
+ const host = hostFromDomainResource(resource)
76
+ if (host) seen.add(host)
77
+ }
78
+ return [...seen]
79
+ }, [resources])
80
+
81
+ const requestedHost = String(router.searchParams?.host || '').trim().toLowerCase().replace(/^www\./, '')
82
+ const host = hosts.includes(requestedHost) ? requestedHost : (hosts[0] || '')
83
+
53
84
  const pageViews = usePageViewStats({
54
85
  workspaceId: workspace?.id,
86
+ host,
55
87
  from,
56
88
  to,
57
89
  limit: 10,
58
90
  })
59
- const workspaceKpis = useWorkspaceKpis({ workspaceId: workspace?.id })
60
-
61
- const { status, totalViews, dailyViews, topPaths } = pageViews
62
- const kpisSettled = workspaceKpis.status === 'success' || workspaceKpis.status === 'error'
63
- const viewsSettled = status === 'success' || status === 'error'
64
- const pageLoading = !kpisSettled || !viewsSettled
65
- const overviewStatus = pageLoading
66
- ? 'loading'
67
- : (workspaceKpis.status === 'error' || status === 'error')
68
- ? 'error'
69
- : 'ready'
91
+
92
+ const { status, totalViews, uniqueVisitors, dailyViews, topPaths, byCountry } = pageViews
93
+ const maxCountryViews = byCountry.reduce((max, item) => Math.max(max, item.views || 0), 0)
94
+ const overviewStatus = overviewStatusFrom({
95
+ listStatus,
96
+ hostCount: hosts.length,
97
+ statsStatus: status,
98
+ })
99
+ const domainsEntitled = isServiceEntitled(workspace?.services, DOMAINS_SERVICE)
100
+ const domainsHref = domainsEntitled
101
+ ? (router.getHref('domains/home') || '/domains')
102
+ : (router.getHref({ id: 'packages/detail', params: { packageSlug: 'domains' } }) || '/packages/domains')
103
+ const emptyCopy = domainsEntitled ? 'analytics.home.empty' : 'analytics.home.emptyEnableDomains'
104
+ const emptyCta = domainsEntitled ? 'analytics.home.addDomain' : 'analytics.home.enableDomains'
105
+
106
+ const selectHost = (nextHost) => {
107
+ if (typeof router.searchParams?.patch === 'function') {
108
+ router.searchParams.patch({ host: nextHost || null })
109
+ return
110
+ }
111
+ if (nextHost) router.searchParams.set('host', nextHost)
112
+ else router.searchParams.delete('host')
113
+ }
70
114
 
71
115
  return (
72
116
  <Page
73
117
  data-analytics-status="on"
74
118
  data-analytics-overview-status={overviewStatus}
119
+ data-analytics-host={host || undefined}
75
120
  title="analytics.home.title"
76
121
  description="analytics.home.description"
122
+ far={hosts.length > 0 ? (
123
+ <Select
124
+ data-analytics-host-picker
125
+ aria-label={t('analytics.home.hostLabel')}
126
+ value={host}
127
+ onChange={(event) => selectHost(event.target.value)}
128
+ >
129
+ {hosts.map((item) => (
130
+ <option key={item} value={item}>{item}</option>
131
+ ))}
132
+ </Select>
133
+ ) : undefined}
77
134
  >
78
135
  <View gap="m">
79
- {pageLoading && (
80
- <SectionPanel title="analytics.home.overview">
136
+ {overviewStatus === 'loading' && (
137
+ <SectionPanel title="analytics.home.traffic">
81
138
  <View inset="m">
82
139
  <Text>{t('analytics.home.loading')}</Text>
83
140
  </View>
84
141
  </SectionPanel>
85
142
  )}
86
143
 
87
- {!pageLoading && workspaceKpis.status === 'error' && (
88
- <SectionPanel title="analytics.home.overview">
89
- <View inset="m">
90
- <Text>{t('analytics.home.workspaceKpisError')}</Text>
91
- </View>
92
- </SectionPanel>
93
- )}
94
-
95
- {!pageLoading && workspaceKpis.status === 'success' && (
96
- <SectionPanel title="analytics.home.overview">
97
- <View layout="row-wrap" gap="m" inset="m">
98
- <SummaryCard label={t('analytics.home.members')} value={`${workspaceKpis.members}`} />
99
- <SummaryCard label={t('analytics.home.resources')} value={`${workspaceKpis.resources}`} />
144
+ {overviewStatus === 'empty' && (
145
+ <SectionPanel title="analytics.home.traffic">
146
+ <View gap="m" inset="m">
147
+ <Text>{t(emptyCopy)}</Text>
148
+ <Button variant="cta" href={domainsHref} data-analytics-add-domain>
149
+ {t(emptyCta)}
150
+ </Button>
100
151
  </View>
101
152
  </SectionPanel>
102
153
  )}
103
154
 
104
- {!pageLoading && status === 'error' && (
155
+ {overviewStatus === 'error' && (
105
156
  <SectionPanel title="analytics.home.traffic">
106
157
  <View inset="m">
107
158
  <Text>{t('analytics.home.error')}</Text>
@@ -109,10 +160,11 @@ export default function AnalyticsProductHome ({ workspace }) {
109
160
  </SectionPanel>
110
161
  )}
111
162
 
112
- {!pageLoading && status === 'success' && (
163
+ {overviewStatus === 'ready' && (
113
164
  <>
114
165
  <SectionPanel title="analytics.home.traffic">
115
166
  <View layout="row-wrap" gap="m" inset="m">
167
+ <SummaryCard label={t('analytics.home.uniqueVisitors')} value={`${uniqueVisitors}`} />
116
168
  <SummaryCard label={t('analytics.home.totalPageViews')} value={`${totalViews}`} />
117
169
  <SummaryCard label={t('analytics.home.trackedPages')} value={`${topPaths.length}`} />
118
170
  <SummaryCard label={t('analytics.home.daysWithTraffic')} value={`${dailyViews.length}`} />
@@ -148,6 +200,60 @@ export default function AnalyticsProductHome ({ workspace }) {
148
200
  </SectionPanel>
149
201
  </View>
150
202
  </View>
203
+
204
+ <SectionPanel title="analytics.home.locations">
205
+ <View gap="s" inset="m">
206
+ {byCountry.length === 0 && (
207
+ <Text variant="small" text="analytics.home.noLocationData" />
208
+ )}
209
+ {byCountry.length > 0 && (
210
+ <View
211
+ layout="row-wrap"
212
+ gap="s"
213
+ data-analytics-locations-map
214
+ style={{ marginBottom: 'var(--space-m)' }}
215
+ >
216
+ {byCountry.map((item) => {
217
+ const intensity = maxCountryViews
218
+ ? Math.max(0.18, item.views / maxCountryViews)
219
+ : 0.18
220
+ return (
221
+ <View
222
+ key={item.countryCode}
223
+ data-country={item.countryCode}
224
+ style={{
225
+ minWidth: 56,
226
+ padding: 'var(--space-s)',
227
+ borderRadius: 'var(--roundness-xs)',
228
+ background: `color-mix(in srgb, var(--color-primary) ${Math.round(intensity * 100)}%, transparent)`,
229
+ textAlign: 'center',
230
+ }}
231
+ >
232
+ <Text variant="small" style={{ fontWeight: 'bold' }}>{item.countryCode}</Text>
233
+ <Text variant="small">{item.views}</Text>
234
+ </View>
235
+ )
236
+ })}
237
+ </View>
238
+ )}
239
+ {byCountry.map((item) => (
240
+ <View key={`row-${item.countryCode}`} layout="row" justifyContent="space-between" gap="m">
241
+ <Text variant="small">{item.countryCode}</Text>
242
+ <View style={{ flex: 1, minWidth: 0, alignSelf: 'center' }}>
243
+ <View
244
+ style={{
245
+ height: 6,
246
+ borderRadius: 999,
247
+ background: 'var(--color-primary)',
248
+ width: `${maxCountryViews ? Math.max(8, (item.views / maxCountryViews) * 100) : 8}%`,
249
+ }}
250
+ />
251
+ </View>
252
+ <Text variant="small">{item.views}</Text>
253
+ </View>
254
+ ))}
255
+ </View>
256
+ </SectionPanel>
151
257
  </>
152
258
  )}
153
259
  </View>
package/src/Definition.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const Definition = {
2
2
  id: 'analytics',
3
3
  title: 'Analytics',
4
- description: 'Workspace insight KPIs for members, resources, and traffic.',
4
+ description: 'Visitor analytics for registered Hosts.',
5
5
  icon: 'chart',
6
6
  status: ['beta'],
7
7
  }
@@ -31,14 +31,14 @@ export function useAnalyticsHomeContent () {
31
31
 
32
32
  const features = [
33
33
  {
34
- title: t('analytics.home.features.members.title'),
34
+ title: t('analytics.home.features.visitors.title'),
35
35
  icon: 'user',
36
- text: t('analytics.home.features.members.text'),
36
+ text: t('analytics.home.features.visitors.text'),
37
37
  },
38
38
  {
39
- title: t('analytics.home.features.resources.title'),
39
+ title: t('analytics.home.features.paths.title'),
40
40
  icon: 'folder',
41
- text: t('analytics.home.features.resources.text'),
41
+ text: t('analytics.home.features.paths.text'),
42
42
  },
43
43
  {
44
44
  title: t('analytics.home.features.traffic.title'),
@@ -15,7 +15,7 @@ export const metadata = {
15
15
  const ANALYTICS_SERVICE = '@ossy/analytics'
16
16
 
17
17
  /**
18
- * Analytics home — product KPIs when signed in and entitled, otherwise sales.
18
+ * Analytics home — Host traffic when signed in and entitled, otherwise sales.
19
19
  * Entitlement comes from the shell workspace snapshot (SSR bootstrap / cookie)
20
20
  * so the dashboard does not flash the sales page on navigation.
21
21
  */
@@ -0,0 +1 @@
1
+ export const metadata = { id: '@ossy/analytics/actions/create-page-view', access: 'public' }
@@ -0,0 +1,55 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { Aggregate } from '@ossy/event-store'
3
+ import { Workspace } from '@ossy/workspaces/server'
4
+ import { ResourcesEvents, commitResource, ResourceStream } from '@ossy/resources/server'
5
+ import {
6
+ hostFromRequest,
7
+ pageViewContentFromPayload,
8
+ resolvePageViewWorkspaceId,
9
+ workspaceIdForRegisteredHost,
10
+ } from './resolve-page-view-workspace.js'
11
+
12
+ export const metadata = { id: '@ossy/analytics/tasks/create-page-view' }
13
+
14
+ const PAGE_VIEW_SCHEMA_ID = '@ossy/web/schema/page-view'
15
+ const PAGE_VIEW_LOCATION = '/@ossy/analytics/page-views/'
16
+
17
+ /**
18
+ * Public ingest. Unknown Host is a silent no-op (200) so the
19
+ * managed-domain registry cannot be enumerated.
20
+ *
21
+ * After Host allowlist: append a page-view resource. Connection `ip` is stamped
22
+ * on the eventstore row (ADR 0016), not on content. `countryCode` is derived
23
+ * later by PageViewLocationProjection (MaxMind).
24
+ */
25
+ export async function run ({ payload, req }) {
26
+ const host = hostFromRequest(req)
27
+ const workspaceId = await resolvePageViewWorkspaceId({
28
+ host,
29
+ findDomain: (name) => workspaceIdForRegisteredHost(ResourceStream.Collection, name),
30
+ })
31
+ if (!workspaceId) return { ok: true, recorded: false }
32
+
33
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
34
+
35
+ const contentInput = pageViewContentFromPayload(payload, {
36
+ userAgent: req?.headers?.['user-agent'],
37
+ path: '/',
38
+ host,
39
+ })
40
+
41
+ const resourceId = nanoid()
42
+ const event = ResourcesEvents.Created({
43
+ resourceId,
44
+ schemaId: PAGE_VIEW_SCHEMA_ID,
45
+ createdBy: req?.userId ?? 'public',
46
+ belongsTo: workspace.id,
47
+ location: PAGE_VIEW_LOCATION,
48
+ name: contentInput.path || '/',
49
+ content: contentInput,
50
+ access: 'restricted',
51
+ })
52
+
53
+ const resource = await commitResource(event)
54
+ return { ok: true, recorded: true, id: resource.id }
55
+ }