@umituz/web-traffic 1.0.6
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/LICENSE +21 -0
- package/README.md +280 -0
- package/package.json +61 -0
- package/src/domains/affiliate/aggregates/affiliate.aggregate.ts +98 -0
- package/src/domains/affiliate/entities/affiliate-visit.entity.ts +52 -0
- package/src/domains/affiliate/index.ts +22 -0
- package/src/domains/affiliate/repositories/affiliate.repository.interface.ts +26 -0
- package/src/domains/affiliate/value-objects/affiliate-id.vo.ts +35 -0
- package/src/domains/affiliate/value-objects/site-id.vo.ts +33 -0
- package/src/domains/analytics/entities/analytics.entity.ts +33 -0
- package/src/domains/analytics/index.ts +18 -0
- package/src/domains/analytics/repositories/analytics.repository.interface.ts +19 -0
- package/src/domains/conversion/aggregates/order.aggregate.ts +85 -0
- package/src/domains/conversion/entities/order-item.entity.ts +27 -0
- package/src/domains/conversion/events/conversion-recorded.domain-event.ts +30 -0
- package/src/domains/conversion/index.ts +21 -0
- package/src/domains/conversion/repositories/conversion.repository.interface.ts +13 -0
- package/src/domains/conversion/value-objects/money.vo.ts +55 -0
- package/src/domains/tracking/aggregates/session.aggregate.ts +154 -0
- package/src/domains/tracking/application/tracking-command.service.ts +109 -0
- package/src/domains/tracking/entities/event.entity.ts +48 -0
- package/src/domains/tracking/entities/pageview.entity.ts +52 -0
- package/src/domains/tracking/events/event-tracked.domain-event.ts +28 -0
- package/src/domains/tracking/events/pageview-tracked.domain-event.ts +31 -0
- package/src/domains/tracking/index.ts +37 -0
- package/src/domains/tracking/repositories/event.repository.interface.ts +29 -0
- package/src/domains/tracking/value-objects/device-info.vo.ts +163 -0
- package/src/domains/tracking/value-objects/event-id.vo.ts +36 -0
- package/src/domains/tracking/value-objects/session-id.vo.ts +36 -0
- package/src/domains/tracking/value-objects/utm-parameters.vo.ts +75 -0
- package/src/index.ts +16 -0
- package/src/infrastructure/analytics/http-analytics.repository.impl.ts +60 -0
- package/src/infrastructure/index.ts +19 -0
- package/src/infrastructure/repositories/http-event.repository.impl.ts +160 -0
- package/src/infrastructure/tracking/web-traffic.service.ts +188 -0
- package/src/presentation/context.tsx +43 -0
- package/src/presentation/hooks.ts +78 -0
- package/src/presentation/index.ts +11 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracking Domain Export
|
|
3
|
+
* Subpath: @umituz/web-traffic/tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Aggregates
|
|
7
|
+
export { Session } from './aggregates/session.aggregate';
|
|
8
|
+
export type { SessionCreateInput } from './aggregates/session.aggregate';
|
|
9
|
+
|
|
10
|
+
// Entities
|
|
11
|
+
export { Event } from './entities/event.entity';
|
|
12
|
+
export type { EventCreateInput } from './entities/event.entity';
|
|
13
|
+
|
|
14
|
+
export { Pageview } from './entities/pageview.entity';
|
|
15
|
+
export type { PageviewCreateInput } from './entities/pageview.entity';
|
|
16
|
+
|
|
17
|
+
// Value Objects
|
|
18
|
+
export { SessionId } from './value-objects/session-id.vo';
|
|
19
|
+
export { EventId } from './value-objects/event-id.vo';
|
|
20
|
+
export { UTMParameters } from './value-objects/utm-parameters.vo';
|
|
21
|
+
export { DeviceInfo } from './value-objects/device-info.vo';
|
|
22
|
+
export type { DeviceType, BrowserInfo, OSInfo } from './value-objects/device-info.vo';
|
|
23
|
+
|
|
24
|
+
// Repository Interfaces
|
|
25
|
+
export type {
|
|
26
|
+
IEventRepository,
|
|
27
|
+
IPageviewRepository,
|
|
28
|
+
ISessionRepository,
|
|
29
|
+
} from './repositories/event.repository.interface';
|
|
30
|
+
|
|
31
|
+
// Domain Events
|
|
32
|
+
export { EventTracked } from './events/event-tracked.domain-event';
|
|
33
|
+
export { PageviewTracked } from './events/pageview-tracked.domain-event';
|
|
34
|
+
|
|
35
|
+
// Application Services
|
|
36
|
+
export { TrackingCommandService } from './application/tracking-command.service';
|
|
37
|
+
export type { TrackingCommandResult } from './application/tracking-command.service';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Repository Interface
|
|
3
|
+
* @description Repository interface for Event persistence (Domain Layer)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Event } from '../entities/event.entity';
|
|
7
|
+
import type { EventId } from '../value-objects/event-id.vo';
|
|
8
|
+
import type { SessionId } from '../value-objects/session-id.vo';
|
|
9
|
+
|
|
10
|
+
export interface IEventRepository {
|
|
11
|
+
save(event: Event): Promise<void>;
|
|
12
|
+
findById(id: EventId): Promise<Event | null>;
|
|
13
|
+
findBySessionId(sessionId: SessionId): Promise<Event[]>;
|
|
14
|
+
delete(id: EventId): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IPageviewRepository {
|
|
18
|
+
save(pageview: import('../entities/pageview.entity').Pageview): Promise<void>;
|
|
19
|
+
findById(id: EventId): Promise<import('../entities/pageview.entity').Pageview | null>;
|
|
20
|
+
findBySessionId(sessionId: SessionId): Promise<import('../entities/pageview.entity').Pageview[]>;
|
|
21
|
+
delete(id: EventId): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ISessionRepository {
|
|
25
|
+
save(session: import('../aggregates/session.aggregate').Session): Promise<void>;
|
|
26
|
+
findById(id: SessionId): Promise<import('../aggregates/session.aggregate').Session | null>;
|
|
27
|
+
findActive(deviceId: string, timeoutMs: number): Promise<import('../aggregates/session.aggregate').Session | null>;
|
|
28
|
+
delete(id: SessionId): Promise<void>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeviceInfo Value Object
|
|
3
|
+
* @description Immutable value object for device/browser information
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
7
|
+
|
|
8
|
+
export interface BrowserInfo {
|
|
9
|
+
readonly name: string | null;
|
|
10
|
+
readonly version: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OSInfo {
|
|
14
|
+
readonly name: string | null;
|
|
15
|
+
readonly version: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class DeviceInfo {
|
|
19
|
+
private readonly browser: BrowserInfo;
|
|
20
|
+
private readonly os: OSInfo;
|
|
21
|
+
private readonly deviceType: DeviceType;
|
|
22
|
+
private readonly screenWidth: number | null;
|
|
23
|
+
private readonly screenHeight: number | null;
|
|
24
|
+
|
|
25
|
+
constructor(params: {
|
|
26
|
+
browser: BrowserInfo;
|
|
27
|
+
os: OSInfo;
|
|
28
|
+
deviceType: DeviceType;
|
|
29
|
+
screenWidth: number | null;
|
|
30
|
+
screenHeight: number | null;
|
|
31
|
+
}) {
|
|
32
|
+
this.browser = { ...params.browser };
|
|
33
|
+
this.os = { ...params.os };
|
|
34
|
+
this.deviceType = params.deviceType;
|
|
35
|
+
this.screenWidth = params.screenWidth;
|
|
36
|
+
this.screenHeight = params.screenHeight;
|
|
37
|
+
Object.freeze(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getBrowser(): BrowserInfo {
|
|
41
|
+
return { ...this.browser };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getOS(): OSInfo {
|
|
45
|
+
return { ...this.os };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getDeviceType(): DeviceType {
|
|
49
|
+
return this.deviceType;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getScreenSize(): { width: number | null; height: number | null } {
|
|
53
|
+
return {
|
|
54
|
+
width: this.screenWidth,
|
|
55
|
+
height: this.screenHeight,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isMobile(): boolean {
|
|
60
|
+
return this.deviceType === 'mobile';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isTablet(): boolean {
|
|
64
|
+
return this.deviceType === 'tablet';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isDesktop(): boolean {
|
|
68
|
+
return this.deviceType === 'desktop';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
toJSON() {
|
|
72
|
+
return {
|
|
73
|
+
browser: this.browser,
|
|
74
|
+
os: this.os,
|
|
75
|
+
deviceType: this.deviceType,
|
|
76
|
+
screenWidth: this.screenWidth,
|
|
77
|
+
screenHeight: this.screenHeight,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static fromUserAgent(userAgent: string, screenWidth?: number): DeviceInfo {
|
|
82
|
+
// Simple UA parsing (production would use ua-parser-js)
|
|
83
|
+
const browser = parseBrowser(userAgent);
|
|
84
|
+
const os = parseOS(userAgent);
|
|
85
|
+
const deviceType = detectDeviceType(userAgent, screenWidth);
|
|
86
|
+
|
|
87
|
+
return new DeviceInfo({
|
|
88
|
+
browser,
|
|
89
|
+
os,
|
|
90
|
+
deviceType,
|
|
91
|
+
screenWidth: screenWidth ?? null,
|
|
92
|
+
screenHeight: null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseBrowser(userAgent: string): BrowserInfo {
|
|
98
|
+
const chromeMatch = userAgent.match(/Chrome\/(\d+\.\d+\.\d+\.\d+)/);
|
|
99
|
+
if (chromeMatch) {
|
|
100
|
+
return { name: 'Chrome', version: chromeMatch[1] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const firefoxMatch = userAgent.match(/Firefox\/(\d+\.\d+)/);
|
|
104
|
+
if (firefoxMatch) {
|
|
105
|
+
return { name: 'Firefox', version: firefoxMatch[1] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const safariMatch = userAgent.match(/Safari\/(\d+\.\d+)/);
|
|
109
|
+
if (safariMatch && !userAgent.includes('Chrome')) {
|
|
110
|
+
return { name: 'Safari', version: safariMatch[1] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const edgeMatch = userAgent.match(/Edg\/(\d+\.\d+\.\d+\.\d+)/);
|
|
114
|
+
if (edgeMatch) {
|
|
115
|
+
return { name: 'Edge', version: edgeMatch[1] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { name: null, version: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseOS(userAgent: string): OSInfo {
|
|
122
|
+
const windowsMatch = userAgent.match(/Windows NT (\d+\.\d+)/);
|
|
123
|
+
if (windowsMatch) {
|
|
124
|
+
return { name: 'Windows', version: windowsMatch[1] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const macMatch = userAgent.match(/Mac OS X (\d+[._]\d+)/);
|
|
128
|
+
if (macMatch) {
|
|
129
|
+
return { name: 'macOS', version: macMatch[1].replace('_', '.') };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const iosMatch = userAgent.match(/iOS (\d+[._]\d+)/);
|
|
133
|
+
if (iosMatch) {
|
|
134
|
+
return { name: 'iOS', version: iosMatch[1].replace('_', '.') };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const androidMatch = userAgent.match(/Android (\d+\.\d+)/);
|
|
138
|
+
if (androidMatch) {
|
|
139
|
+
return { name: 'Android', version: androidMatch[1] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const linuxMatch = userAgent.includes('Linux');
|
|
143
|
+
if (linuxMatch) {
|
|
144
|
+
return { name: 'Linux', version: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { name: null, version: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function detectDeviceType(userAgent: string, screenWidth?: number): DeviceType {
|
|
151
|
+
if (screenWidth) {
|
|
152
|
+
if (screenWidth < 768) return 'mobile';
|
|
153
|
+
if (screenWidth < 1024) return 'tablet';
|
|
154
|
+
return 'desktop';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const mobile = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent);
|
|
158
|
+
if (mobile) {
|
|
159
|
+
return /iPad/i.test(userAgent) ? 'tablet' : 'mobile';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return 'desktop';
|
|
163
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventId Value Object
|
|
3
|
+
* @description Immutable value object for event identification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class EventId {
|
|
7
|
+
private readonly value: string;
|
|
8
|
+
|
|
9
|
+
constructor(value: string) {
|
|
10
|
+
if (!value || value.trim().length === 0) {
|
|
11
|
+
throw new Error('EventId cannot be empty');
|
|
12
|
+
}
|
|
13
|
+
if (!value.startsWith('event-')) {
|
|
14
|
+
throw new Error('EventId must start with "event-"');
|
|
15
|
+
}
|
|
16
|
+
this.value = value;
|
|
17
|
+
Object.freeze(this);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
equals(other: EventId): boolean {
|
|
21
|
+
return this.value === other.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toString(): string {
|
|
25
|
+
return this.value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getValue(): string {
|
|
29
|
+
return this.value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static generate(): EventId {
|
|
33
|
+
const id = `event-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
34
|
+
return new EventId(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionId Value Object
|
|
3
|
+
* @description Immutable value object for session identification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class SessionId {
|
|
7
|
+
private readonly value: string;
|
|
8
|
+
|
|
9
|
+
constructor(value: string) {
|
|
10
|
+
if (!value || value.trim().length === 0) {
|
|
11
|
+
throw new Error('SessionId cannot be empty');
|
|
12
|
+
}
|
|
13
|
+
if (!value.startsWith('session-')) {
|
|
14
|
+
throw new Error('SessionId must start with "session-"');
|
|
15
|
+
}
|
|
16
|
+
this.value = value;
|
|
17
|
+
Object.freeze(this);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
equals(other: SessionId): boolean {
|
|
21
|
+
return this.value === other.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toString(): string {
|
|
25
|
+
return this.value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getValue(): string {
|
|
29
|
+
return this.value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static generate(): SessionId {
|
|
33
|
+
const id = `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
34
|
+
return new SessionId(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTMParameters Value Object
|
|
3
|
+
* @description Immutable value object for UTM campaign parameters
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class UTMParameters {
|
|
7
|
+
private readonly source?: string;
|
|
8
|
+
private readonly medium?: string;
|
|
9
|
+
private readonly campaign?: string;
|
|
10
|
+
private readonly term?: string;
|
|
11
|
+
private readonly content?: string;
|
|
12
|
+
|
|
13
|
+
constructor(params: {
|
|
14
|
+
source?: string;
|
|
15
|
+
medium?: string;
|
|
16
|
+
campaign?: string;
|
|
17
|
+
term?: string;
|
|
18
|
+
content?: string;
|
|
19
|
+
}) {
|
|
20
|
+
this.source = params.source;
|
|
21
|
+
this.medium = params.medium;
|
|
22
|
+
this.campaign = params.campaign;
|
|
23
|
+
this.term = params.term;
|
|
24
|
+
this.content = params.content;
|
|
25
|
+
Object.freeze(this);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getSource(): string | undefined {
|
|
29
|
+
return this.source;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getMedium(): string | undefined {
|
|
33
|
+
return this.medium;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getCampaign(): string | undefined {
|
|
37
|
+
return this.campaign;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getTerm(): string | undefined {
|
|
41
|
+
return this.term;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getContent(): string | undefined {
|
|
45
|
+
return this.content;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hasAnyUTM(): boolean {
|
|
49
|
+
return !!(this.source || this.medium || this.campaign || this.term || this.content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toJSON() {
|
|
53
|
+
return {
|
|
54
|
+
source: this.source,
|
|
55
|
+
medium: this.medium,
|
|
56
|
+
campaign: this.campaign,
|
|
57
|
+
term: this.term,
|
|
58
|
+
content: this.content,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static fromURLSearchParams(searchParams: URLSearchParams): UTMParameters | null {
|
|
63
|
+
const source = searchParams.get('utm_source') || undefined;
|
|
64
|
+
const medium = searchParams.get('utm_medium') || undefined;
|
|
65
|
+
const campaign = searchParams.get('utm_campaign') || undefined;
|
|
66
|
+
const term = searchParams.get('utm_term') || undefined;
|
|
67
|
+
const content = searchParams.get('utm_content') || undefined;
|
|
68
|
+
|
|
69
|
+
if (!source && !medium && !campaign && !term && !content) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new UTMParameters({ source, medium, campaign, term, content });
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @umituz/web-traffic
|
|
3
|
+
* Web analytics tracking library with DDD architecture
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: Apps should use subpath imports, NOT this root barrel.
|
|
6
|
+
*
|
|
7
|
+
* RECOMMENDED IMPORTS:
|
|
8
|
+
* - @umituz/web-traffic/presentation (React hooks & Provider)
|
|
9
|
+
* - @umituz/web-traffic/tracking (Tracking domain)
|
|
10
|
+
* - @umituz/web-traffic/conversion (Conversion domain)
|
|
11
|
+
* - @umituz/web-traffic/analytics (Analytics domain)
|
|
12
|
+
* - @umituz/web-traffic/infrastructure (Infrastructure services)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Presentation Layer
|
|
16
|
+
export * from './presentation';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Analytics Repository Implementation
|
|
3
|
+
* @description HTTP-based implementation of IAnalyticsRepository
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
IAnalyticsRepository,
|
|
8
|
+
AnalyticsQuery,
|
|
9
|
+
} from '../../domains/analytics/repositories/analytics.repository.interface';
|
|
10
|
+
import type { AnalyticsData } from '../../domains/analytics/entities/analytics.entity';
|
|
11
|
+
|
|
12
|
+
export interface HTTPAnalyticsConfig {
|
|
13
|
+
readonly apiUrl: string;
|
|
14
|
+
readonly apiKey: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HTTPAnalyticsRepository implements IAnalyticsRepository {
|
|
18
|
+
constructor(private readonly config: HTTPAnalyticsConfig) {}
|
|
19
|
+
|
|
20
|
+
async getAnalytics(query: AnalyticsQuery): Promise<AnalyticsData | null> {
|
|
21
|
+
try {
|
|
22
|
+
const params = new URLSearchParams({
|
|
23
|
+
start_date: query.startDate.toISOString(),
|
|
24
|
+
end_date: query.endDate.toISOString(),
|
|
25
|
+
...(query.path && { path: query.path }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const response = await fetch(`${this.config.apiUrl}/analytics?${params}`, {
|
|
29
|
+
headers: {
|
|
30
|
+
'X-API-Key': this.config.apiKey,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
console.error('Analytics fetch failed:', response.statusText);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return await response.json();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Analytics error:', error);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getPageviews(query: AnalyticsQuery): Promise<number> {
|
|
47
|
+
const data = await this.getAnalytics(query);
|
|
48
|
+
return data?.pageviews ?? 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getSessions(query: AnalyticsQuery): Promise<number> {
|
|
52
|
+
const data = await this.getAnalytics(query);
|
|
53
|
+
return data?.sessions ?? 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getVisitors(query: AnalyticsQuery): Promise<number> {
|
|
57
|
+
const data = await this.getAnalytics(query);
|
|
58
|
+
return data?.visitors ?? 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure Export
|
|
3
|
+
* Subpath: @umituz/web-traffic/infrastructure
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Repositories
|
|
7
|
+
export {
|
|
8
|
+
HTTPEventRepository,
|
|
9
|
+
HTTPPageviewRepository,
|
|
10
|
+
LocalSessionRepository,
|
|
11
|
+
} from './repositories/http-event.repository.impl';
|
|
12
|
+
export type { HTTPRepositoryConfig } from './repositories/http-event.repository.impl';
|
|
13
|
+
|
|
14
|
+
export { HTTPAnalyticsRepository } from './analytics/http-analytics.repository.impl';
|
|
15
|
+
export type { HTTPAnalyticsConfig } from './analytics/http-analytics.repository.impl';
|
|
16
|
+
|
|
17
|
+
// Services
|
|
18
|
+
export { webTrafficService } from './tracking/web-traffic.service';
|
|
19
|
+
export type { WebTrafficConfig } from './tracking/web-traffic.service';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Event Repository Implementation
|
|
3
|
+
* @description HTTP-based implementation of IEventRepository
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
IEventRepository,
|
|
8
|
+
IPageviewRepository,
|
|
9
|
+
ISessionRepository,
|
|
10
|
+
} from '../../domains/tracking/repositories/event.repository.interface';
|
|
11
|
+
import type { Event } from '../../domains/tracking/entities/event.entity';
|
|
12
|
+
import type { Pageview } from '../../domains/tracking/entities/pageview.entity';
|
|
13
|
+
import type { Session } from '../../domains/tracking/aggregates/session.aggregate';
|
|
14
|
+
import { EventId } from '../../domains/tracking/value-objects/event-id.vo';
|
|
15
|
+
import { SessionId } from '../../domains/tracking/value-objects/session-id.vo';
|
|
16
|
+
|
|
17
|
+
export interface HTTPRepositoryConfig {
|
|
18
|
+
readonly apiUrl: string;
|
|
19
|
+
readonly apiKey: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class HTTPEventRepository implements IEventRepository {
|
|
23
|
+
private queue: Array<{ event: Event | Pageview; timestamp: number }> = [];
|
|
24
|
+
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
25
|
+
private readonly FLUSH_INTERVAL = 30000; // 30 seconds
|
|
26
|
+
private readonly MAX_QUEUE_SIZE = 100;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly config: HTTPRepositoryConfig) {
|
|
29
|
+
this.startFlushTimer();
|
|
30
|
+
if (typeof window !== 'undefined') {
|
|
31
|
+
window.addEventListener('beforeunload', () => this.flush());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async save(event: Event): Promise<void> {
|
|
36
|
+
this.queue.push({ event, timestamp: Date.now() });
|
|
37
|
+
if (this.queue.length >= 10) {
|
|
38
|
+
await this.flush();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async findById(id: EventId): Promise<Event | null> {
|
|
43
|
+
// For HTTP repository, we don't fetch individual events
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async findBySessionId(sessionId: SessionId): Promise<Event[]> {
|
|
48
|
+
// Would need an endpoint to fetch events by session
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async delete(id: EventId): Promise<void> {
|
|
53
|
+
// Implement if needed
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private startFlushTimer(): void {
|
|
57
|
+
this.flushTimer = setInterval(() => {
|
|
58
|
+
this.flush();
|
|
59
|
+
}, this.FLUSH_INTERVAL);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async flush(): Promise<void> {
|
|
63
|
+
if (this.queue.length === 0) return;
|
|
64
|
+
|
|
65
|
+
const items = [...this.queue];
|
|
66
|
+
this.queue = [];
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`${this.config.apiUrl}/track`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'X-API-Key': this.config.apiKey,
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
events: items.map((item) => item.event.toJSON()),
|
|
77
|
+
}),
|
|
78
|
+
keepalive: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
console.error('Tracking failed:', response.statusText);
|
|
83
|
+
this.queue.unshift(...items);
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Tracking error:', error);
|
|
87
|
+
this.queue.unshift(...items);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
destroy(): void {
|
|
92
|
+
if (this.flushTimer) {
|
|
93
|
+
clearInterval(this.flushTimer);
|
|
94
|
+
}
|
|
95
|
+
this.flush();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class HTTPPageviewRepository implements IPageviewRepository {
|
|
100
|
+
private eventRepo: IEventRepository;
|
|
101
|
+
|
|
102
|
+
constructor(config: HTTPRepositoryConfig) {
|
|
103
|
+
this.eventRepo = new HTTPEventRepository(config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async save(pageview: Pageview): Promise<void> {
|
|
107
|
+
await this.eventRepo.save(pageview as any);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async findById(id: EventId): Promise<Pageview | null> {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async findBySessionId(sessionId: SessionId): Promise<Pageview[]> {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async delete(id: EventId): Promise<void> {
|
|
119
|
+
// Implement if needed
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class LocalSessionRepository implements ISessionRepository {
|
|
124
|
+
private sessions = new Map<string, Session>();
|
|
125
|
+
|
|
126
|
+
async save(session: Session): Promise<void> {
|
|
127
|
+
this.sessions.set(session.id.toString(), session);
|
|
128
|
+
if (typeof window !== 'undefined') {
|
|
129
|
+
localStorage.setItem('wt_session', JSON.stringify(session.toJSON()));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async findById(id: SessionId): Promise<Session | null> {
|
|
134
|
+
return this.sessions.get(id.toString()) || null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async findActive(deviceId: string, timeoutMs: number): Promise<Session | null> {
|
|
138
|
+
if (typeof window === 'undefined') return null;
|
|
139
|
+
|
|
140
|
+
const stored = localStorage.getItem('wt_session');
|
|
141
|
+
if (!stored) return null;
|
|
142
|
+
|
|
143
|
+
const data = JSON.parse(stored);
|
|
144
|
+
const sessionId = new SessionId(data.id);
|
|
145
|
+
|
|
146
|
+
const session = this.sessions.get(sessionId.toString());
|
|
147
|
+
if (session && session.isActive(timeoutMs)) {
|
|
148
|
+
return session;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async delete(id: SessionId): Promise<void> {
|
|
155
|
+
this.sessions.delete(id.toString());
|
|
156
|
+
if (typeof window !== 'undefined') {
|
|
157
|
+
localStorage.removeItem('wt_session');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|