@coursebuilder/analytics 1.1.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/LICENSE +21 -0
- package/dist/api/index.d.ts +158 -0
- package/dist/api/index.js +317 -0
- package/dist/api/index.js.map +1 -0
- package/dist/catalog.d.ts +14 -0
- package/dist/catalog.js +209 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/index.d.ts +172 -0
- package/dist/components/index.js +1258 -0
- package/dist/components/index.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.js +350 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/database.d.ts +79 -0
- package/dist/providers/database.js +533 -0
- package/dist/providers/database.js.map +1 -0
- package/dist/providers/derived.d.ts +45 -0
- package/dist/providers/derived.js +32 -0
- package/dist/providers/derived.js.map +1 -0
- package/dist/providers/ga4.d.ts +43 -0
- package/dist/providers/ga4.js +220 -0
- package/dist/providers/ga4.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +1239 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/mux.d.ts +103 -0
- package/dist/providers/mux.js +241 -0
- package/dist/providers/mux.js.map +1 -0
- package/dist/providers/survey.d.ts +102 -0
- package/dist/providers/survey.js +233 -0
- package/dist/providers/survey.js.map +1 -0
- package/dist/types.d.ts +303 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -0
- package/src/api/catalog-handler.ts +321 -0
- package/src/api/index.ts +4 -0
- package/src/api/token-handler.ts +71 -0
- package/src/catalog.ts +223 -0
- package/src/components/country-chart.tsx +114 -0
- package/src/components/index.ts +5 -0
- package/src/components/omnibus-dashboard.tsx +1460 -0
- package/src/components/revenue-chart.tsx +251 -0
- package/src/components/use-chart-colors.ts +75 -0
- package/src/engine.ts +201 -0
- package/src/index.ts +7 -0
- package/src/providers/database.ts +795 -0
- package/src/providers/derived.ts +79 -0
- package/src/providers/ga4.ts +173 -0
- package/src/providers/index.ts +44 -0
- package/src/providers/mux.ts +438 -0
- package/src/providers/survey.ts +487 -0
- package/src/types.ts +333 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Skill Recordings Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { QueryOptions, QueryResult, SurfaceName, SurfaceMap, AnalyticsRange as AnalyticsRange$1, RevenueSummary, ConversionFunnel, AttributionCoverage, TrafficOverview, YouTubeChannelOverview, TrafficRevenueCorrelation, TrafficDaily, RevenueDaily, YouTubeRevenueCorrelation, YouTubeDaily, SurveySummary, EmailRevenueOverview, SurveyRevenueCorrelation, RevenueByProduct, RevenueByCountry, RecentPurchase, AttributionCount, ShortlinkPerformance, RevenueBySource, ContentCorrelation, TopPage, TrafficSource, YouTubeVideoPerformance, YouTubeTrafficSource, SurveyListItem, SurveyResponsesDaily, SurveyQuestionBreakdown, SurveyResponseRow } from '../types.js';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { SurfaceEntry } from '../catalog.js';
|
|
4
|
+
|
|
5
|
+
type AnalyticsRange = '24h' | '7d' | '30d' | '90d' | 'all';
|
|
6
|
+
interface CatalogHandlerDeps {
|
|
7
|
+
engine: {
|
|
8
|
+
query: (surface: string, options?: QueryOptions) => Promise<QueryResult<SurfaceName>>;
|
|
9
|
+
getCatalog: () => SurfaceEntry[];
|
|
10
|
+
};
|
|
11
|
+
checkAccess: (request: Request) => Promise<{
|
|
12
|
+
authorized: boolean;
|
|
13
|
+
user?: Record<string, unknown> | null;
|
|
14
|
+
authMethod?: string;
|
|
15
|
+
}>;
|
|
16
|
+
logger?: {
|
|
17
|
+
info: (message: string, data?: Record<string, unknown>) => unknown;
|
|
18
|
+
warn: (message: string, data?: Record<string, unknown>) => unknown;
|
|
19
|
+
error: (message: string, data?: Record<string, unknown>) => unknown;
|
|
20
|
+
};
|
|
21
|
+
appName?: string;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a Next.js App Router GET handler that serves a HATEOAS-style
|
|
27
|
+
* analytics catalog and surface query API.
|
|
28
|
+
*
|
|
29
|
+
* @param deps - Handler dependencies including engine, access check, and
|
|
30
|
+
* optional logger and app metadata
|
|
31
|
+
* @returns An object with `GET` and `OPTIONS` Next.js route handlers
|
|
32
|
+
*/
|
|
33
|
+
declare function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps): {
|
|
34
|
+
GET: (request: NextRequest) => Promise<NextResponse<{
|
|
35
|
+
ok: boolean;
|
|
36
|
+
endpoint: string;
|
|
37
|
+
error: {
|
|
38
|
+
message: string;
|
|
39
|
+
code: string;
|
|
40
|
+
};
|
|
41
|
+
fix: string;
|
|
42
|
+
next_actions: {
|
|
43
|
+
command: string;
|
|
44
|
+
description: string;
|
|
45
|
+
}[];
|
|
46
|
+
}> | NextResponse<{
|
|
47
|
+
ok: boolean;
|
|
48
|
+
endpoint: string;
|
|
49
|
+
description: string;
|
|
50
|
+
notes: string[];
|
|
51
|
+
surfaces: SurfaceEntry[];
|
|
52
|
+
_links: {
|
|
53
|
+
self: {
|
|
54
|
+
href: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
next_actions: {
|
|
58
|
+
command: string;
|
|
59
|
+
description: string;
|
|
60
|
+
params: {
|
|
61
|
+
surface: {
|
|
62
|
+
required: boolean;
|
|
63
|
+
enum: (keyof SurfaceMap)[];
|
|
64
|
+
description: string;
|
|
65
|
+
};
|
|
66
|
+
range: {
|
|
67
|
+
default: string;
|
|
68
|
+
enum: AnalyticsRange[];
|
|
69
|
+
description: string;
|
|
70
|
+
};
|
|
71
|
+
limit: {
|
|
72
|
+
default: string;
|
|
73
|
+
description: string;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}[];
|
|
77
|
+
}> | NextResponse<{
|
|
78
|
+
ok: boolean;
|
|
79
|
+
endpoint: string;
|
|
80
|
+
surface: string;
|
|
81
|
+
range: AnalyticsRange$1;
|
|
82
|
+
description: string;
|
|
83
|
+
data: RevenueSummary | ConversionFunnel | AttributionCoverage | TrafficOverview | YouTubeChannelOverview | TrafficRevenueCorrelation | TrafficDaily[] | RevenueDaily[] | YouTubeRevenueCorrelation | YouTubeDaily[] | SurveySummary | EmailRevenueOverview | SurveyRevenueCorrelation | RevenueByProduct[] | RevenueByCountry[] | RecentPurchase[] | AttributionCount[] | ShortlinkPerformance[] | RevenueBySource[] | ContentCorrelation[] | TopPage[] | TrafficSource[] | YouTubeVideoPerformance[] | YouTubeTrafficSource[] | SurveyListItem[] | SurveyResponsesDaily[] | SurveyQuestionBreakdown[] | SurveyResponseRow[];
|
|
84
|
+
meta: {
|
|
85
|
+
totalRows: number;
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
queryTimeMs: number;
|
|
88
|
+
};
|
|
89
|
+
_links: {
|
|
90
|
+
self: {
|
|
91
|
+
href: string;
|
|
92
|
+
};
|
|
93
|
+
catalog: {
|
|
94
|
+
href: string;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
next_actions: {
|
|
98
|
+
command: string;
|
|
99
|
+
description: string;
|
|
100
|
+
params: {
|
|
101
|
+
range: {
|
|
102
|
+
value: AnalyticsRange;
|
|
103
|
+
enum: AnalyticsRange[];
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
}[];
|
|
107
|
+
}>>;
|
|
108
|
+
OPTIONS: () => NextResponse<{}>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
interface TokenHandlerDeps {
|
|
112
|
+
db: {
|
|
113
|
+
insert: (table: any) => {
|
|
114
|
+
values: (data: any) => Promise<any>;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
deviceAccessToken: unknown;
|
|
118
|
+
checkAccess: (request: Request) => Promise<{
|
|
119
|
+
authorized: boolean;
|
|
120
|
+
userId?: string;
|
|
121
|
+
user?: {
|
|
122
|
+
email?: string;
|
|
123
|
+
[key: string]: unknown;
|
|
124
|
+
} | null;
|
|
125
|
+
}>;
|
|
126
|
+
ttlHours?: number;
|
|
127
|
+
logger?: {
|
|
128
|
+
info: (message: string, data?: Record<string, unknown>) => unknown;
|
|
129
|
+
warn: (message: string, data?: Record<string, unknown>) => unknown;
|
|
130
|
+
error: (message: string, data?: Record<string, unknown>) => unknown;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Creates a Next.js App Router POST handler that generates short-lived
|
|
136
|
+
* device access tokens for analytics API authentication.
|
|
137
|
+
*
|
|
138
|
+
* Tokens are stored with only `token` and `userId`. Expiry is **not**
|
|
139
|
+
* stored in the database — it is enforced at lookup time by comparing
|
|
140
|
+
* the row's `createdAt` timestamp against the configured TTL. Each
|
|
141
|
+
* app's `getUserAbilityForRequest` must enforce this check.
|
|
142
|
+
*
|
|
143
|
+
* @param deps - Handler dependencies including db, deviceAccessToken schema
|
|
144
|
+
* table, an access-check function, and optional TTL and logger overrides
|
|
145
|
+
* @returns An object with a `POST` Next.js route handler
|
|
146
|
+
*/
|
|
147
|
+
declare function createTokenHandler(deps: TokenHandlerDeps): {
|
|
148
|
+
POST: (request: NextRequest) => Promise<NextResponse<{
|
|
149
|
+
error: string;
|
|
150
|
+
}> | NextResponse<{
|
|
151
|
+
token: string;
|
|
152
|
+
ttl: string;
|
|
153
|
+
ttlLabel: string;
|
|
154
|
+
expiresAt: string;
|
|
155
|
+
}>>;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export { type CatalogHandlerDeps, type TokenHandlerDeps, createAnalyticsCatalogHandler, createTokenHandler };
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/api/catalog-handler.ts
|
|
5
|
+
import { NextResponse } from "next/server";
|
|
6
|
+
var VALID_RANGES = /* @__PURE__ */ new Set([
|
|
7
|
+
"24h",
|
|
8
|
+
"7d",
|
|
9
|
+
"30d",
|
|
10
|
+
"90d",
|
|
11
|
+
"all"
|
|
12
|
+
]);
|
|
13
|
+
var RANGE_OPTIONS = [
|
|
14
|
+
"24h",
|
|
15
|
+
"7d",
|
|
16
|
+
"30d",
|
|
17
|
+
"90d",
|
|
18
|
+
"all"
|
|
19
|
+
];
|
|
20
|
+
var CATEGORY_SUGGESTIONS = {
|
|
21
|
+
revenue: [
|
|
22
|
+
"revenue/daily",
|
|
23
|
+
"revenue/products",
|
|
24
|
+
"attribution/sources",
|
|
25
|
+
"correlation/traffic-revenue"
|
|
26
|
+
],
|
|
27
|
+
attribution: [
|
|
28
|
+
"attribution/funnel",
|
|
29
|
+
"attribution/sources",
|
|
30
|
+
"attribution/coverage",
|
|
31
|
+
"correlation/traffic-revenue"
|
|
32
|
+
],
|
|
33
|
+
traffic: [
|
|
34
|
+
"traffic/daily",
|
|
35
|
+
"traffic/sources",
|
|
36
|
+
"correlation/traffic-revenue",
|
|
37
|
+
"correlation/youtube-revenue"
|
|
38
|
+
],
|
|
39
|
+
youtube: [
|
|
40
|
+
"youtube/videos",
|
|
41
|
+
"youtube/daily",
|
|
42
|
+
"youtube/sources",
|
|
43
|
+
"correlation/youtube-revenue"
|
|
44
|
+
],
|
|
45
|
+
correlation: [
|
|
46
|
+
"summary",
|
|
47
|
+
"attribution/funnel",
|
|
48
|
+
"youtube",
|
|
49
|
+
"correlation/survey-revenue"
|
|
50
|
+
],
|
|
51
|
+
survey: [
|
|
52
|
+
"surveys",
|
|
53
|
+
"surveys/list",
|
|
54
|
+
"surveys/daily",
|
|
55
|
+
"surveys/questions",
|
|
56
|
+
"surveys/responses"
|
|
57
|
+
]
|
|
58
|
+
};
|
|
59
|
+
var corsHeaders = {
|
|
60
|
+
"Access-Control-Allow-Origin": "*",
|
|
61
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
62
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
63
|
+
};
|
|
64
|
+
function parseRange(raw) {
|
|
65
|
+
if (raw && VALID_RANGES.has(raw)) {
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
return "30d";
|
|
69
|
+
}
|
|
70
|
+
__name(parseRange, "parseRange");
|
|
71
|
+
function getMeta(data, queryTimeMs, truncated) {
|
|
72
|
+
return {
|
|
73
|
+
totalRows: Array.isArray(data) ? data.length : 1,
|
|
74
|
+
truncated,
|
|
75
|
+
queryTimeMs
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
__name(getMeta, "getMeta");
|
|
79
|
+
function createAnalyticsCatalogHandler(deps) {
|
|
80
|
+
const { engine, checkAccess, logger, appName, baseUrl } = deps;
|
|
81
|
+
const catalog = engine.getCatalog();
|
|
82
|
+
const catalogByName = Object.fromEntries(catalog.map((entry) => [
|
|
83
|
+
entry.name,
|
|
84
|
+
entry
|
|
85
|
+
]));
|
|
86
|
+
function buildContextualNextActions(surface, range, endpointPath) {
|
|
87
|
+
const entry = catalogByName[surface];
|
|
88
|
+
if (!entry)
|
|
89
|
+
return [];
|
|
90
|
+
const suggestions = CATEGORY_SUGGESTIONS[entry.category] ?? [];
|
|
91
|
+
return suggestions.filter((name) => name !== surface).slice(0, 4).map((name) => ({
|
|
92
|
+
command: `GET ${endpointPath}?surface=${name}&range=<range>`,
|
|
93
|
+
description: catalogByName[name]?.description ?? name,
|
|
94
|
+
params: {
|
|
95
|
+
range: {
|
|
96
|
+
value: range,
|
|
97
|
+
enum: RANGE_OPTIONS
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
__name(buildContextualNextActions, "buildContextualNextActions");
|
|
103
|
+
const OPTIONS = /* @__PURE__ */ __name(() => NextResponse.json({}, {
|
|
104
|
+
headers: corsHeaders
|
|
105
|
+
}), "OPTIONS");
|
|
106
|
+
const GET = /* @__PURE__ */ __name(async (request) => {
|
|
107
|
+
const requestUrl = new URL(request.url);
|
|
108
|
+
const resolvedBase = baseUrl ?? `${requestUrl.protocol}//${requestUrl.host}`;
|
|
109
|
+
const endpointPath = `${requestUrl.pathname}`;
|
|
110
|
+
const appLabel = appName ?? "Analytics";
|
|
111
|
+
const access = await checkAccess(request);
|
|
112
|
+
if (!access.authorized) {
|
|
113
|
+
if (logger) {
|
|
114
|
+
void logger.warn("api.analytics.access-denied", {
|
|
115
|
+
userId: access.user?.id ?? null,
|
|
116
|
+
email: access.user?.email ?? null,
|
|
117
|
+
authMethod: access.authMethod ?? "unknown",
|
|
118
|
+
hasAuthorization: false
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return NextResponse.json({
|
|
122
|
+
ok: false,
|
|
123
|
+
endpoint: endpointPath,
|
|
124
|
+
error: {
|
|
125
|
+
message: "Unauthorized",
|
|
126
|
+
code: "AUTH_REQUIRED"
|
|
127
|
+
},
|
|
128
|
+
fix: "Authenticate with an admin device token or an admin session cookie.",
|
|
129
|
+
next_actions: [
|
|
130
|
+
{
|
|
131
|
+
command: "GET /api/coursebuilder/devices",
|
|
132
|
+
description: "Start device verification flow to obtain a Bearer token"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
command: "GET /login",
|
|
136
|
+
description: "Log in as an admin to use session-based auth"
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}, {
|
|
140
|
+
status: 401,
|
|
141
|
+
headers: corsHeaders
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const { searchParams } = requestUrl;
|
|
145
|
+
const rawSurface = searchParams.get("surface");
|
|
146
|
+
if (!rawSurface) {
|
|
147
|
+
return NextResponse.json({
|
|
148
|
+
ok: true,
|
|
149
|
+
endpoint: endpointPath,
|
|
150
|
+
description: `${appLabel} analytics \u2014 revenue, attribution, traffic, YouTube, and content correlation`,
|
|
151
|
+
notes: [
|
|
152
|
+
"YouTube surfaces are useful for correlation/content analysis but lag by about 48 hours."
|
|
153
|
+
],
|
|
154
|
+
surfaces: catalog,
|
|
155
|
+
_links: {
|
|
156
|
+
self: {
|
|
157
|
+
href: `${resolvedBase}${endpointPath}`
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
next_actions: [
|
|
161
|
+
{
|
|
162
|
+
command: `GET ${endpointPath}?surface=<surface>&range=<range>&limit=<limit>`,
|
|
163
|
+
description: "Query a specific analytics surface",
|
|
164
|
+
params: {
|
|
165
|
+
surface: {
|
|
166
|
+
required: true,
|
|
167
|
+
enum: catalog.map((entry) => entry.name),
|
|
168
|
+
description: "Analytics surface to query"
|
|
169
|
+
},
|
|
170
|
+
range: {
|
|
171
|
+
default: "30d",
|
|
172
|
+
enum: RANGE_OPTIONS,
|
|
173
|
+
description: "Time range"
|
|
174
|
+
},
|
|
175
|
+
limit: {
|
|
176
|
+
default: "20",
|
|
177
|
+
description: "Max rows for surfaces that support it (max 100)"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}, {
|
|
183
|
+
headers: corsHeaders
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (!(rawSurface in catalogByName)) {
|
|
187
|
+
return NextResponse.json({
|
|
188
|
+
ok: false,
|
|
189
|
+
endpoint: endpointPath,
|
|
190
|
+
error: {
|
|
191
|
+
message: `Unknown surface: ${rawSurface}`,
|
|
192
|
+
code: "INVALID_SURFACE"
|
|
193
|
+
},
|
|
194
|
+
fix: `Hit GET ${endpointPath} with no params for the full surface catalog.`,
|
|
195
|
+
next_actions: [
|
|
196
|
+
{
|
|
197
|
+
command: `GET ${endpointPath}`,
|
|
198
|
+
description: "Browse the full analytics surface catalog"
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
}, {
|
|
202
|
+
status: 400,
|
|
203
|
+
headers: corsHeaders
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const surface = rawSurface;
|
|
207
|
+
const range = parseRange(searchParams.get("range"));
|
|
208
|
+
const limit = Math.min(Number(searchParams.get("limit") ?? 20), 100);
|
|
209
|
+
if (logger) {
|
|
210
|
+
logger.info("api.analytics.query", {
|
|
211
|
+
userId: access.user?.id ?? null,
|
|
212
|
+
email: access.user?.email ?? null,
|
|
213
|
+
authMethod: access.authMethod ?? "unknown",
|
|
214
|
+
surface,
|
|
215
|
+
range,
|
|
216
|
+
limit
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const result = await engine.query(surface, {
|
|
220
|
+
range,
|
|
221
|
+
limit
|
|
222
|
+
});
|
|
223
|
+
if (!result.ok) {
|
|
224
|
+
if (logger) {
|
|
225
|
+
logger.error("api.analytics.error", {
|
|
226
|
+
userId: access.user?.id ?? null,
|
|
227
|
+
surface,
|
|
228
|
+
range,
|
|
229
|
+
code: result.error.code,
|
|
230
|
+
error: result.error.message
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return NextResponse.json({
|
|
234
|
+
ok: false,
|
|
235
|
+
endpoint: endpointPath,
|
|
236
|
+
surface,
|
|
237
|
+
error: result.error,
|
|
238
|
+
fix: result.fix,
|
|
239
|
+
next_actions: buildContextualNextActions(surface, range, endpointPath)
|
|
240
|
+
}, {
|
|
241
|
+
status: result.error.code.endsWith("_UNAVAILABLE") ? 503 : 500,
|
|
242
|
+
headers: corsHeaders
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return NextResponse.json({
|
|
246
|
+
ok: true,
|
|
247
|
+
endpoint: endpointPath,
|
|
248
|
+
surface,
|
|
249
|
+
range: result.range,
|
|
250
|
+
description: catalogByName[surface]?.description,
|
|
251
|
+
data: result.data,
|
|
252
|
+
meta: getMeta(result.data, result.meta.queryTimeMs, result.meta.truncated),
|
|
253
|
+
_links: {
|
|
254
|
+
self: {
|
|
255
|
+
href: `${resolvedBase}${endpointPath}?surface=${surface}&range=${range}`
|
|
256
|
+
},
|
|
257
|
+
catalog: {
|
|
258
|
+
href: `${resolvedBase}${endpointPath}`
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
next_actions: buildContextualNextActions(surface, range, endpointPath)
|
|
262
|
+
}, {
|
|
263
|
+
headers: corsHeaders
|
|
264
|
+
});
|
|
265
|
+
}, "GET");
|
|
266
|
+
return {
|
|
267
|
+
GET,
|
|
268
|
+
OPTIONS
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
__name(createAnalyticsCatalogHandler, "createAnalyticsCatalogHandler");
|
|
272
|
+
|
|
273
|
+
// src/api/token-handler.ts
|
|
274
|
+
import { NextResponse as NextResponse2 } from "next/server";
|
|
275
|
+
function createTokenHandler(deps) {
|
|
276
|
+
const { db, deviceAccessToken, checkAccess, logger } = deps;
|
|
277
|
+
const ttlHours = deps.ttlHours ?? 24;
|
|
278
|
+
const ttlLabel = `${ttlHours} hours`;
|
|
279
|
+
const POST = /* @__PURE__ */ __name(async (request) => {
|
|
280
|
+
const access = await checkAccess(request);
|
|
281
|
+
if (!access.authorized || !access.userId) {
|
|
282
|
+
return NextResponse2.json({
|
|
283
|
+
error: "Unauthorized"
|
|
284
|
+
}, {
|
|
285
|
+
status: 401
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const userId = access.userId;
|
|
289
|
+
const token = crypto.randomUUID();
|
|
290
|
+
await db.insert(deviceAccessToken).values({
|
|
291
|
+
token,
|
|
292
|
+
userId
|
|
293
|
+
});
|
|
294
|
+
if (logger) {
|
|
295
|
+
void logger.info("api.analytics.token-generated", {
|
|
296
|
+
userId,
|
|
297
|
+
email: access.user?.email ?? null,
|
|
298
|
+
ttlHours
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return NextResponse2.json({
|
|
302
|
+
token,
|
|
303
|
+
ttl: `${ttlHours}h`,
|
|
304
|
+
ttlLabel,
|
|
305
|
+
expiresAt: new Date(Date.now() + ttlHours * 60 * 60 * 1e3).toISOString()
|
|
306
|
+
});
|
|
307
|
+
}, "POST");
|
|
308
|
+
return {
|
|
309
|
+
POST
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
__name(createTokenHandler, "createTokenHandler");
|
|
313
|
+
export {
|
|
314
|
+
createAnalyticsCatalogHandler,
|
|
315
|
+
createTokenHandler
|
|
316
|
+
};
|
|
317
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/catalog-handler.ts","../../src/api/token-handler.ts"],"sourcesContent":["import { NextRequest, NextResponse } from 'next/server'\n\nimport type { SurfaceEntry } from '../catalog'\nimport type { QueryOptions, QueryResult, SurfaceName } from '../types'\n\ntype AnalyticsRange = '24h' | '7d' | '30d' | '90d' | 'all'\n\ninterface CatalogHandlerDeps {\n\tengine: {\n\t\tquery: (\n\t\t\tsurface: string,\n\t\t\toptions?: QueryOptions,\n\t\t) => Promise<QueryResult<SurfaceName>>\n\t\tgetCatalog: () => SurfaceEntry[]\n\t}\n\tcheckAccess: (request: Request) => Promise<{\n\t\tauthorized: boolean\n\t\tuser?: Record<string, unknown> | null\n\t\tauthMethod?: string\n\t}>\n\tlogger?: {\n\t\tinfo: (message: string, data?: Record<string, unknown>) => unknown\n\t\twarn: (message: string, data?: Record<string, unknown>) => unknown\n\t\terror: (message: string, data?: Record<string, unknown>) => unknown\n\t}\n\tappName?: string\n\tbaseUrl?: string\n}\n\nexport type { CatalogHandlerDeps }\n\nconst VALID_RANGES = new Set<AnalyticsRange>(['24h', '7d', '30d', '90d', 'all'])\nconst RANGE_OPTIONS: AnalyticsRange[] = ['24h', '7d', '30d', '90d', 'all']\n\nconst CATEGORY_SUGGESTIONS: Record<string, string[]> = {\n\trevenue: [\n\t\t'revenue/daily',\n\t\t'revenue/products',\n\t\t'attribution/sources',\n\t\t'correlation/traffic-revenue',\n\t],\n\tattribution: [\n\t\t'attribution/funnel',\n\t\t'attribution/sources',\n\t\t'attribution/coverage',\n\t\t'correlation/traffic-revenue',\n\t],\n\ttraffic: [\n\t\t'traffic/daily',\n\t\t'traffic/sources',\n\t\t'correlation/traffic-revenue',\n\t\t'correlation/youtube-revenue',\n\t],\n\tyoutube: [\n\t\t'youtube/videos',\n\t\t'youtube/daily',\n\t\t'youtube/sources',\n\t\t'correlation/youtube-revenue',\n\t],\n\tcorrelation: [\n\t\t'summary',\n\t\t'attribution/funnel',\n\t\t'youtube',\n\t\t'correlation/survey-revenue',\n\t],\n\tsurvey: [\n\t\t'surveys',\n\t\t'surveys/list',\n\t\t'surveys/daily',\n\t\t'surveys/questions',\n\t\t'surveys/responses',\n\t],\n}\n\nconst corsHeaders = {\n\t'Access-Control-Allow-Origin': '*',\n\t'Access-Control-Allow-Methods': 'GET, OPTIONS',\n\t'Access-Control-Allow-Headers': 'Content-Type, Authorization',\n}\n\nfunction parseRange(raw?: string | null): AnalyticsRange {\n\tif (raw && VALID_RANGES.has(raw as AnalyticsRange)) {\n\t\treturn raw as AnalyticsRange\n\t}\n\n\treturn '30d'\n}\n\nfunction getMeta(data: unknown, queryTimeMs: number, truncated: boolean) {\n\treturn {\n\t\ttotalRows: Array.isArray(data) ? data.length : 1,\n\t\ttruncated,\n\t\tqueryTimeMs,\n\t}\n}\n\n/**\n * Creates a Next.js App Router GET handler that serves a HATEOAS-style\n * analytics catalog and surface query API.\n *\n * @param deps - Handler dependencies including engine, access check, and\n * optional logger and app metadata\n * @returns An object with `GET` and `OPTIONS` Next.js route handlers\n */\nexport function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps) {\n\tconst { engine, checkAccess, logger, appName, baseUrl } = deps\n\n\tconst catalog = engine.getCatalog()\n\tconst catalogByName = Object.fromEntries(\n\t\tcatalog.map((entry) => [entry.name, entry]),\n\t) as Record<string, SurfaceEntry>\n\n\tfunction buildContextualNextActions(\n\t\tsurface: string,\n\t\trange: AnalyticsRange,\n\t\tendpointPath: string,\n\t) {\n\t\tconst entry = catalogByName[surface]\n\t\tif (!entry) return []\n\t\tconst suggestions = CATEGORY_SUGGESTIONS[entry.category] ?? []\n\n\t\treturn suggestions\n\t\t\t.filter((name) => name !== surface)\n\t\t\t.slice(0, 4)\n\t\t\t.map((name) => ({\n\t\t\t\tcommand: `GET ${endpointPath}?surface=${name}&range=<range>`,\n\t\t\t\tdescription: catalogByName[name]?.description ?? name,\n\t\t\t\tparams: {\n\t\t\t\t\trange: {\n\t\t\t\t\t\tvalue: range,\n\t\t\t\t\t\tenum: RANGE_OPTIONS,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}))\n\t}\n\n\tconst OPTIONS = () => NextResponse.json({}, { headers: corsHeaders })\n\n\tconst GET = async (request: NextRequest) => {\n\t\tconst requestUrl = new URL(request.url)\n\t\tconst resolvedBase = baseUrl ?? `${requestUrl.protocol}//${requestUrl.host}`\n\t\tconst endpointPath = `${requestUrl.pathname}`\n\t\tconst appLabel = appName ?? 'Analytics'\n\n\t\tconst access = await checkAccess(request)\n\n\t\tif (!access.authorized) {\n\t\t\tif (logger) {\n\t\t\t\tvoid logger.warn('api.analytics.access-denied', {\n\t\t\t\t\tuserId: access.user?.id ?? null,\n\t\t\t\t\temail: access.user?.email ?? null,\n\t\t\t\t\tauthMethod: access.authMethod ?? 'unknown',\n\t\t\t\t\thasAuthorization: false,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\treturn NextResponse.json(\n\t\t\t\t{\n\t\t\t\t\tok: false,\n\t\t\t\t\tendpoint: endpointPath,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tmessage: 'Unauthorized',\n\t\t\t\t\t\tcode: 'AUTH_REQUIRED',\n\t\t\t\t\t},\n\t\t\t\t\tfix: 'Authenticate with an admin device token or an admin session cookie.',\n\t\t\t\t\tnext_actions: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcommand: 'GET /api/coursebuilder/devices',\n\t\t\t\t\t\t\tdescription:\n\t\t\t\t\t\t\t\t'Start device verification flow to obtain a Bearer token',\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcommand: 'GET /login',\n\t\t\t\t\t\t\tdescription: 'Log in as an admin to use session-based auth',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{ status: 401, headers: corsHeaders },\n\t\t\t)\n\t\t}\n\n\t\tconst { searchParams } = requestUrl\n\t\tconst rawSurface = searchParams.get('surface')\n\n\t\tif (!rawSurface) {\n\t\t\treturn NextResponse.json(\n\t\t\t\t{\n\t\t\t\t\tok: true,\n\t\t\t\t\tendpoint: endpointPath,\n\t\t\t\t\tdescription: `${appLabel} analytics — revenue, attribution, traffic, YouTube, and content correlation`,\n\t\t\t\t\tnotes: [\n\t\t\t\t\t\t'YouTube surfaces are useful for correlation/content analysis but lag by about 48 hours.',\n\t\t\t\t\t],\n\t\t\t\t\tsurfaces: catalog,\n\t\t\t\t\t_links: {\n\t\t\t\t\t\tself: { href: `${resolvedBase}${endpointPath}` },\n\t\t\t\t\t},\n\t\t\t\t\tnext_actions: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcommand: `GET ${endpointPath}?surface=<surface>&range=<range>&limit=<limit>`,\n\t\t\t\t\t\t\tdescription: 'Query a specific analytics surface',\n\t\t\t\t\t\t\tparams: {\n\t\t\t\t\t\t\t\tsurface: {\n\t\t\t\t\t\t\t\t\trequired: true,\n\t\t\t\t\t\t\t\t\tenum: catalog.map((entry) => entry.name),\n\t\t\t\t\t\t\t\t\tdescription: 'Analytics surface to query',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\trange: {\n\t\t\t\t\t\t\t\t\tdefault: '30d',\n\t\t\t\t\t\t\t\t\tenum: RANGE_OPTIONS,\n\t\t\t\t\t\t\t\t\tdescription: 'Time range',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tlimit: {\n\t\t\t\t\t\t\t\t\tdefault: '20',\n\t\t\t\t\t\t\t\t\tdescription:\n\t\t\t\t\t\t\t\t\t\t'Max rows for surfaces that support it (max 100)',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{ headers: corsHeaders },\n\t\t\t)\n\t\t}\n\n\t\tif (!(rawSurface in catalogByName)) {\n\t\t\treturn NextResponse.json(\n\t\t\t\t{\n\t\t\t\t\tok: false,\n\t\t\t\t\tendpoint: endpointPath,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tmessage: `Unknown surface: ${rawSurface}`,\n\t\t\t\t\t\tcode: 'INVALID_SURFACE',\n\t\t\t\t\t},\n\t\t\t\t\tfix: `Hit GET ${endpointPath} with no params for the full surface catalog.`,\n\t\t\t\t\tnext_actions: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcommand: `GET ${endpointPath}`,\n\t\t\t\t\t\t\tdescription: 'Browse the full analytics surface catalog',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{ status: 400, headers: corsHeaders },\n\t\t\t)\n\t\t}\n\n\t\tconst surface = rawSurface\n\t\tconst range = parseRange(searchParams.get('range'))\n\t\tconst limit = Math.min(Number(searchParams.get('limit') ?? 20), 100)\n\n\t\tif (logger) {\n\t\t\tlogger.info('api.analytics.query', {\n\t\t\t\tuserId: access.user?.id ?? null,\n\t\t\t\temail: access.user?.email ?? null,\n\t\t\t\tauthMethod: access.authMethod ?? 'unknown',\n\t\t\t\tsurface,\n\t\t\t\trange,\n\t\t\t\tlimit,\n\t\t\t})\n\t\t}\n\n\t\tconst result = await engine.query(surface, { range, limit })\n\n\t\tif (!result.ok) {\n\t\t\tif (logger) {\n\t\t\t\tlogger.error('api.analytics.error', {\n\t\t\t\t\tuserId: access.user?.id ?? null,\n\t\t\t\t\tsurface,\n\t\t\t\t\trange,\n\t\t\t\t\tcode: result.error.code,\n\t\t\t\t\terror: result.error.message,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\treturn NextResponse.json(\n\t\t\t\t{\n\t\t\t\t\tok: false,\n\t\t\t\t\tendpoint: endpointPath,\n\t\t\t\t\tsurface,\n\t\t\t\t\terror: result.error,\n\t\t\t\t\tfix: result.fix,\n\t\t\t\t\tnext_actions: buildContextualNextActions(\n\t\t\t\t\t\tsurface,\n\t\t\t\t\t\trange,\n\t\t\t\t\t\tendpointPath,\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tstatus: result.error.code.endsWith('_UNAVAILABLE') ? 503 : 500,\n\t\t\t\t\theaders: corsHeaders,\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\n\t\treturn NextResponse.json(\n\t\t\t{\n\t\t\t\tok: true,\n\t\t\t\tendpoint: endpointPath,\n\t\t\t\tsurface,\n\t\t\t\trange: result.range,\n\t\t\t\tdescription: catalogByName[surface]?.description,\n\t\t\t\tdata: result.data,\n\t\t\t\tmeta: getMeta(\n\t\t\t\t\tresult.data,\n\t\t\t\t\tresult.meta.queryTimeMs,\n\t\t\t\t\tresult.meta.truncated,\n\t\t\t\t),\n\t\t\t\t_links: {\n\t\t\t\t\tself: {\n\t\t\t\t\t\thref: `${resolvedBase}${endpointPath}?surface=${surface}&range=${range}`,\n\t\t\t\t\t},\n\t\t\t\t\tcatalog: { href: `${resolvedBase}${endpointPath}` },\n\t\t\t\t},\n\t\t\t\tnext_actions: buildContextualNextActions(surface, range, endpointPath),\n\t\t\t},\n\t\t\t{ headers: corsHeaders },\n\t\t)\n\t}\n\n\treturn { GET, OPTIONS }\n}\n","import { NextRequest, NextResponse } from 'next/server'\n\ninterface TokenHandlerDeps {\n\tdb: { insert: (table: any) => { values: (data: any) => Promise<any> } }\n\tdeviceAccessToken: unknown\n\tcheckAccess: (request: Request) => Promise<{\n\t\tauthorized: boolean\n\t\tuserId?: string\n\t\tuser?: { email?: string; [key: string]: unknown } | null\n\t}>\n\tttlHours?: number\n\tlogger?: {\n\t\tinfo: (message: string, data?: Record<string, unknown>) => unknown\n\t\twarn: (message: string, data?: Record<string, unknown>) => unknown\n\t\terror: (message: string, data?: Record<string, unknown>) => unknown\n\t}\n}\n\nexport type { TokenHandlerDeps }\n\n/**\n * Creates a Next.js App Router POST handler that generates short-lived\n * device access tokens for analytics API authentication.\n *\n * Tokens are stored with only `token` and `userId`. Expiry is **not**\n * stored in the database — it is enforced at lookup time by comparing\n * the row's `createdAt` timestamp against the configured TTL. Each\n * app's `getUserAbilityForRequest` must enforce this check.\n *\n * @param deps - Handler dependencies including db, deviceAccessToken schema\n * table, an access-check function, and optional TTL and logger overrides\n * @returns An object with a `POST` Next.js route handler\n */\nexport function createTokenHandler(deps: TokenHandlerDeps) {\n\tconst { db, deviceAccessToken, checkAccess, logger } = deps\n\tconst ttlHours = deps.ttlHours ?? 24\n\tconst ttlLabel = `${ttlHours} hours`\n\n\tconst POST = async (request: NextRequest) => {\n\t\tconst access = await checkAccess(request)\n\n\t\tif (!access.authorized || !access.userId) {\n\t\t\treturn NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\t\t}\n\n\t\tconst userId = access.userId\n\t\tconst token = crypto.randomUUID()\n\n\t\tawait db.insert(deviceAccessToken).values({\n\t\t\ttoken,\n\t\t\tuserId,\n\t\t})\n\n\t\tif (logger) {\n\t\t\tvoid logger.info('api.analytics.token-generated', {\n\t\t\t\tuserId,\n\t\t\t\temail: access.user?.email ?? null,\n\t\t\t\tttlHours,\n\t\t\t})\n\t\t}\n\n\t\treturn NextResponse.json({\n\t\t\ttoken,\n\t\t\tttl: `${ttlHours}h`,\n\t\t\tttlLabel,\n\t\t\texpiresAt: new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString(),\n\t\t})\n\t}\n\n\treturn { POST }\n}\n"],"mappings":"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","names":["NextResponse","VALID_RANGES","Set","RANGE_OPTIONS","CATEGORY_SUGGESTIONS","revenue","attribution","traffic","youtube","correlation","survey","corsHeaders","parseRange","raw","has","getMeta","data","queryTimeMs","truncated","totalRows","Array","isArray","length","createAnalyticsCatalogHandler","deps","engine","checkAccess","logger","appName","baseUrl","catalog","getCatalog","catalogByName","Object","fromEntries","map","entry","name","buildContextualNextActions","surface","range","endpointPath","suggestions","category","filter","slice","command","description","params","value","enum","OPTIONS","NextResponse","json","headers","GET","request","requestUrl","URL","url","resolvedBase","protocol","host","pathname","appLabel","access","authorized","warn","userId","user","id","email","authMethod","hasAuthorization","ok","endpoint","error","message","code","fix","next_actions","status","searchParams","rawSurface","get","notes","surfaces","_links","self","href","required","default","limit","Math","min","Number","info","result","query","endsWith","meta","NextResponse","createTokenHandler","deps","db","deviceAccessToken","checkAccess","logger","ttlHours","ttlLabel","POST","request","access","authorized","userId","NextResponse","json","error","status","token","crypto","randomUUID","insert","values","info","email","user","ttl","expiresAt","Date","now","toISOString"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SurfaceMap, SurfaceName } from './types.js';
|
|
2
|
+
|
|
3
|
+
interface SurfaceEntry {
|
|
4
|
+
name: SurfaceName;
|
|
5
|
+
description: string;
|
|
6
|
+
category: 'revenue' | 'attribution' | 'traffic' | 'youtube' | 'correlation' | 'survey';
|
|
7
|
+
provider: 'database' | 'ga4' | 'youtube' | 'derived' | 'newsletter' | 'survey';
|
|
8
|
+
fn: string;
|
|
9
|
+
unavailableFix?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const catalog: Record<SurfaceName, SurfaceEntry>;
|
|
12
|
+
declare const ANALYTICS_CATALOG: Record<keyof SurfaceMap, SurfaceEntry>;
|
|
13
|
+
|
|
14
|
+
export { ANALYTICS_CATALOG, type SurfaceEntry, catalog };
|