feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@cio/analytics",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
|
||||
"dev": "tsc --watch",
|
||||
"format": "prettier --write \"src/**/*.ts\""
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client/index.d.ts",
|
||||
"default": "./dist/client/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@cio/db": "workspace:*",
|
||||
"@cio/tsconfig": "workspace:*",
|
||||
"@cio/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"prettier": "^3.2.5",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function sendBeacon(endpoint: string, payload: unknown): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
try {
|
||||
const blob = new Blob([body], { type: 'application/json' });
|
||||
if (navigator.sendBeacon(endpoint, blob)) return true;
|
||||
} catch {
|
||||
// fall through to fetch
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof fetch === 'function') {
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true,
|
||||
credentials: 'same-origin'
|
||||
}).catch(() => {
|
||||
// analytics must never throw
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { CLIENT_EVENTS, type ClientEventType } from '../events/taxonomy';
|
||||
import { getSessionId, isDoNotTrack } from './session';
|
||||
import { sendBeacon } from './beacon';
|
||||
|
||||
type TrackProps = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
type QueuedEvent = {
|
||||
eventType: ClientEventType;
|
||||
occurredAt: string;
|
||||
orgId?: string;
|
||||
courseId?: string;
|
||||
path?: string;
|
||||
referrerHost?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
locale?: string;
|
||||
props?: TrackProps;
|
||||
};
|
||||
|
||||
type AnalyticsConfig = {
|
||||
endpoint: string;
|
||||
orgId?: string;
|
||||
batchSize?: number;
|
||||
flushIntervalMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_BATCH = 10;
|
||||
const DEFAULT_INTERVAL = 2000;
|
||||
|
||||
let config: AnalyticsConfig | null = null;
|
||||
let queue: QueuedEvent[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unloadBound = false;
|
||||
|
||||
function pickReferrerHost(): string | undefined {
|
||||
if (typeof document === 'undefined' || !document.referrer) return undefined;
|
||||
try {
|
||||
return new URL(document.referrer).host || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pickUtm(): Pick<QueuedEvent, 'utmSource' | 'utmMedium' | 'utmCampaign'> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
return {
|
||||
utmSource: params.get('utm_source') ?? undefined,
|
||||
utmMedium: params.get('utm_medium') ?? undefined,
|
||||
utmCampaign: params.get('utm_campaign') ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (!config || queue.length === 0) return;
|
||||
|
||||
const sessionId = getSessionId();
|
||||
if (!sessionId) {
|
||||
// Session cookie not yet set by server hook; drop silently.
|
||||
queue = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
sendBeacon(config.endpoint, { sessionId, events: batch });
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (!config || timer) return;
|
||||
timer = setTimeout(flush, config.flushIntervalMs ?? DEFAULT_INTERVAL);
|
||||
}
|
||||
|
||||
function bindUnload() {
|
||||
if (unloadBound || typeof window === 'undefined') return;
|
||||
unloadBound = true;
|
||||
window.addEventListener('pagehide', flush);
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush();
|
||||
});
|
||||
}
|
||||
|
||||
export function init(opts: AnalyticsConfig): void {
|
||||
if (isDoNotTrack()) return;
|
||||
config = { batchSize: DEFAULT_BATCH, flushIntervalMs: DEFAULT_INTERVAL, ...opts };
|
||||
bindUnload();
|
||||
}
|
||||
|
||||
export function track(eventType: ClientEventType, fields: Partial<QueuedEvent> = {}): void {
|
||||
if (!config || isDoNotTrack()) return;
|
||||
|
||||
const event: QueuedEvent = {
|
||||
eventType,
|
||||
occurredAt: new Date().toISOString(),
|
||||
orgId: fields.orgId ?? config.orgId,
|
||||
courseId: fields.courseId,
|
||||
path: fields.path ?? (typeof window !== 'undefined' ? window.location.pathname : undefined),
|
||||
referrerHost: fields.referrerHost ?? pickReferrerHost(),
|
||||
locale: fields.locale ?? (typeof navigator !== 'undefined' ? navigator.language : undefined),
|
||||
props: fields.props,
|
||||
...pickUtm()
|
||||
};
|
||||
|
||||
queue.push(event);
|
||||
if (queue.length >= (config.batchSize ?? DEFAULT_BATCH)) {
|
||||
flush();
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
export function pageView(fields: Partial<QueuedEvent> & { kind?: 'landing' | 'course' } = {}): void {
|
||||
const eventType: ClientEventType =
|
||||
fields.kind === 'course' ? CLIENT_EVENTS.COURSE_PAGE_VIEW : CLIENT_EVENTS.LANDING_VIEW;
|
||||
track(eventType, fields);
|
||||
}
|
||||
|
||||
export function flushNow(): void {
|
||||
flush();
|
||||
}
|
||||
|
||||
export const analytics = { init, track, pageView, flushNow };
|
||||
@@ -0,0 +1,19 @@
|
||||
const SESSION_COOKIE = 'cio_aid';
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const match = document.cookie.split('; ').find((row) => row.startsWith(`${name}=`));
|
||||
return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : null;
|
||||
}
|
||||
|
||||
export function getSessionId(): string | null {
|
||||
return readCookie(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export function isDoNotTrack(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const dnt =
|
||||
(navigator as Navigator & { msDoNotTrack?: string }).doNotTrack ??
|
||||
(navigator as Navigator & { msDoNotTrack?: string }).msDoNotTrack;
|
||||
return dnt === '1' || dnt === 'yes';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './taxonomy';
|
||||
@@ -0,0 +1,28 @@
|
||||
export const CLIENT_EVENTS = {
|
||||
LANDING_VIEW: 'landing_view',
|
||||
COURSE_PAGE_VIEW: 'course_page_view',
|
||||
SIGNUP_VIEW: 'signup_view',
|
||||
SIGNIN_VIEW: 'signin_view',
|
||||
PRICING_VIEW: 'pricing_view',
|
||||
CTA_CLICK: 'cta_click'
|
||||
} as const;
|
||||
|
||||
export const SERVER_EVENTS = {
|
||||
ENROLLMENT_STARTED: 'enrollment_started',
|
||||
ENROLLMENT_COMPLETED: 'enrollment_completed',
|
||||
COURSE_COMPLETED: 'course_completed',
|
||||
CERTIFICATE_ISSUED: 'certificate_issued'
|
||||
} as const;
|
||||
|
||||
export const ANALYTICS_EVENTS = {
|
||||
...CLIENT_EVENTS,
|
||||
...SERVER_EVENTS
|
||||
} as const;
|
||||
|
||||
export type ClientEventType = (typeof CLIENT_EVENTS)[keyof typeof CLIENT_EVENTS];
|
||||
export type ServerEventType = (typeof SERVER_EVENTS)[keyof typeof SERVER_EVENTS];
|
||||
export type AnalyticsEventType = ClientEventType | ServerEventType;
|
||||
|
||||
export const CLIENT_EVENT_TYPES = Object.values(CLIENT_EVENTS);
|
||||
export const SERVER_EVENT_TYPES = Object.values(SERVER_EVENTS);
|
||||
export const ALL_EVENT_TYPES = [...CLIENT_EVENT_TYPES, ...SERVER_EVENT_TYPES] as const;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './events';
|
||||
export * from './services';
|
||||
export * from './jobs';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './rollup-daily';
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
selectCountryDailyAggregates,
|
||||
selectCourseDailyAggregates,
|
||||
selectOrgDailyAggregates,
|
||||
upsertCountryDailyRows,
|
||||
upsertCourseDailyRows,
|
||||
upsertOrgDailyRows
|
||||
} from '@cio/db/queries/analytics';
|
||||
|
||||
type RollupResult = {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
orgRows: number;
|
||||
courseRows: number;
|
||||
countryRows: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute UTC midnight boundaries for the day `daysAgo` before today.
|
||||
* daysAgo=1 → yesterday (full day window).
|
||||
*/
|
||||
function getDayBoundsUtc(daysAgo: number): { startIso: string; endIso: string } {
|
||||
const now = new Date();
|
||||
const target = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - daysAgo));
|
||||
const next = new Date(target);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return { startIso: target.toISOString(), endIso: next.toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates raw page events into the daily rollup tables (org / course /
|
||||
* country) for a single UTC day window. Idempotent — UPSERT keyed on the
|
||||
* unique constraints. Safe to re-run.
|
||||
*
|
||||
* Default window: yesterday (00:00–24:00 UTC).
|
||||
*/
|
||||
export async function runAnalyticsRollupDaily(opts: { daysAgo?: number } = {}): Promise<RollupResult> {
|
||||
const daysAgo = opts.daysAgo ?? 1;
|
||||
const { startIso, endIso } = getDayBoundsUtc(daysAgo);
|
||||
|
||||
const [orgAgg, courseAgg, countryAgg] = await Promise.all([
|
||||
selectOrgDailyAggregates(startIso, endIso),
|
||||
selectCourseDailyAggregates(startIso, endIso),
|
||||
selectCountryDailyAggregates(startIso, endIso)
|
||||
]);
|
||||
|
||||
const orgRows = orgAgg
|
||||
.filter((row) => row.orgId)
|
||||
.map((row) => ({
|
||||
orgId: row.orgId as string,
|
||||
date: row.date,
|
||||
landingViews: row.landingViews,
|
||||
uniqueVisitors: row.uniqueVisitors,
|
||||
coursePageViews: row.coursePageViews,
|
||||
enrollments: row.enrollments,
|
||||
completions: row.completions
|
||||
}));
|
||||
|
||||
const courseRows = courseAgg
|
||||
.filter((row) => row.courseId && row.orgId)
|
||||
.map((row) => ({
|
||||
courseId: row.courseId as string,
|
||||
orgId: row.orgId as string,
|
||||
date: row.date,
|
||||
views: row.views,
|
||||
uniqueVisitors: row.uniqueVisitors,
|
||||
enrollments: row.enrollments,
|
||||
completions: row.completions
|
||||
}));
|
||||
|
||||
const countryRows = countryAgg
|
||||
.filter((row) => row.orgId && row.country)
|
||||
.map((row) => ({
|
||||
orgId: row.orgId as string,
|
||||
date: row.date,
|
||||
country: row.country as string,
|
||||
views: row.views,
|
||||
enrollments: row.enrollments
|
||||
}));
|
||||
|
||||
const [orgWritten, courseWritten, countryWritten] = await Promise.all([
|
||||
upsertOrgDailyRows(orgRows),
|
||||
upsertCourseDailyRows(courseRows),
|
||||
upsertCountryDailyRows(countryRows)
|
||||
]);
|
||||
|
||||
return {
|
||||
windowStart: startIso,
|
||||
windowEnd: endIso,
|
||||
orgRows: orgWritten,
|
||||
courseRows: courseWritten,
|
||||
countryRows: countryWritten
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './track';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { insertPageEvents } from '@cio/db/queries/analytics';
|
||||
|
||||
import { SERVER_EVENTS, type ServerEventType } from '../events/taxonomy';
|
||||
|
||||
type ServerEventInput = {
|
||||
eventType: ServerEventType;
|
||||
orgId?: string | null;
|
||||
userId?: string | null;
|
||||
courseId?: string | null;
|
||||
sessionId?: string | null;
|
||||
path?: string | null;
|
||||
country?: string | null;
|
||||
locale?: string | null;
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire-and-forget server-side analytics write. Never throws — analytics must
|
||||
* not break the user flow that triggered the event.
|
||||
*/
|
||||
export function trackServerEvent(input: ServerEventInput): void {
|
||||
void writeServerEvent(input).catch((error) => {
|
||||
console.error('trackServerEvent error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeServerEvent(input: ServerEventInput): Promise<void> {
|
||||
if (!input.eventType) return;
|
||||
|
||||
await insertPageEvents([
|
||||
{
|
||||
eventType: input.eventType,
|
||||
orgId: input.orgId ?? null,
|
||||
userId: input.userId ?? null,
|
||||
courseId: input.courseId ?? null,
|
||||
sessionId: input.sessionId ?? `server:${input.eventType}:${input.userId ?? 'anon'}`,
|
||||
path: input.path ?? null,
|
||||
country: input.country ?? null,
|
||||
locale: input.locale ?? null,
|
||||
props: (input.props ?? {}) as Record<string, unknown>
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
export { SERVER_EVENTS };
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "@cio/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"],
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@cio/db": ["../db/dist/index.d.ts"],
|
||||
"@cio/db/*": ["../db/dist/*"],
|
||||
"@cio/utils": ["../utils/dist/index.d.ts"],
|
||||
"@cio/utils/*": ["../utils/dist/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
|
||||
"references": [{ "path": "../db" }, { "path": "../utils" }],
|
||||
"tsc-alias": {
|
||||
"resolveFullPaths": true,
|
||||
"verbose": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user