feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+36
View File
@@ -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"
}
}
+29
View File
@@ -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;
}
+127
View File
@@ -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 };
+19
View File
@@ -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';
}
+1
View File
@@ -0,0 +1 @@
export * from './taxonomy';
+28
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export * from './events';
export * from './services';
export * from './jobs';
+1
View File
@@ -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
};
}
+1
View File
@@ -0,0 +1 @@
export * from './track';
+45
View File
@@ -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 };
+32
View File
@@ -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
}
}