Skip to content

Commit 2cbdcb2

Browse files
authored
Merge pull request #2332 from tekdi/release-1.13.0-prod
Release 1.13.0 prod to learner prod
2 parents c2cf5a0 + 207bbfd commit 2cbdcb2

File tree

9 files changed

+1047
-83
lines changed

9 files changed

+1047
-83
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import "server-only";
2+
3+
import { NextRequest, NextResponse } from "next/server";
4+
import { BetaAnalyticsDataClient } from "@google-analytics/data";
5+
// import serviceAccount from "../../../../utils/service-account";
6+
interface ServiceAccountConfig {
7+
type?: string;
8+
project_id?: string;
9+
private_key_id?: string;
10+
private_key?: string;
11+
client_email?: string;
12+
client_id?: string;
13+
auth_uri?: string;
14+
token_uri?: string;
15+
auth_provider_x509_cert_url?: string;
16+
client_x509_cert_url?: string;
17+
universe_domain?: string;
18+
}
19+
20+
let decodedFromB64: ServiceAccountConfig | null = null;
21+
22+
if (process.env.GOOGLE_SERVICE_ACCOUNT_B64) {
23+
try {
24+
decodedFromB64 = JSON.parse(
25+
Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_B64, "base64").toString(
26+
"utf8"
27+
)
28+
);
29+
} catch (error) {
30+
console.error("Failed to decode GOOGLE_SERVICE_ACCOUNT_B64:", error);
31+
}
32+
}
33+
34+
// Helper function to properly format private key
35+
function formatPrivateKey(key: string | undefined): string | undefined {
36+
if (!key) return undefined;
37+
38+
// First, handle the case where the key might be wrapped in quotes
39+
let formatted = key.replace(/^"|"$/g, '');
40+
41+
// If it's already properly formatted (has newlines), just return it
42+
if (formatted.includes('\n') && formatted.includes('-----BEGIN PRIVATE KEY-----')) {
43+
return formatted;
44+
}
45+
46+
// Handle literal "\n" strings (common in .env files)
47+
if (formatted.includes('\\n')) {
48+
formatted = formatted.replace(/\\n/g, '\n');
49+
} else {
50+
// If no newlines and no literal "\n", it might be a single line string
51+
// Try to insert newlines around headers if they exist but are on the same line
52+
formatted = formatted
53+
.replace('-----BEGIN PRIVATE KEY-----', '-----BEGIN PRIVATE KEY-----\n')
54+
.replace('-----END PRIVATE KEY-----', '\n-----END PRIVATE KEY-----');
55+
56+
// If the body is still one long string, we might need to chunk it (PEM format usually requires 64 char lines)
57+
// However, Node's crypto usually handles single-line bodies if headers are correct.
58+
// Let's at least ensure headers are on their own lines.
59+
}
60+
61+
// Remove any extra whitespace but preserve the structure
62+
formatted = formatted.trim();
63+
64+
return formatted;
65+
}
66+
67+
const serviceAccount = decodedFromB64 ?? {
68+
type: process.env.GOOGLE_TYPE,
69+
project_id: process.env.GOOGLE_PROJECT_ID,
70+
private_key_id: process.env.GOOGLE_PRIVATE_KEY_ID,
71+
private_key: formatPrivateKey(process.env.GOOGLE_PRIVATE_KEY),
72+
client_email: process.env.GOOGLE_CLIENT_EMAIL,
73+
client_id: process.env.GOOGLE_CLIENT_ID,
74+
auth_uri: process.env.GOOGLE_AUTH_URI,
75+
token_uri: process.env.GOOGLE_TOKEN_URI,
76+
auth_provider_x509_cert_url: process.env.GOOGLE_AUTH_PROVIDER_CERT_URL,
77+
client_x509_cert_url: process.env.GOOGLE_CLIENT_CERT_URL,
78+
universe_domain: process.env.GOOGLE_UNIVERSE_DOMAIN,
79+
};
80+
81+
// Ensure private_key is properly formatted even if decoded from base64
82+
if (serviceAccount.private_key && typeof serviceAccount.private_key === "string") {
83+
serviceAccount.private_key = formatPrivateKey(serviceAccount.private_key);
84+
}console.log("Analytics Route Module Loading...");
85+
86+
const PROPERTY_ID = (process.env.GOOGLE_ANALYTICS_PROPERTY_ID || "496861567").replace(/['"]/g, '');
87+
88+
// Lazy initialization function - only called when route is accessed, not at build time
89+
function getAnalyticsClient(): BetaAnalyticsDataClient {
90+
if (!process.env.GOOGLE_ANALYTICS_PROPERTY_ID) {
91+
console.warn("GOOGLE_ANALYTICS_PROPERTY_ID is not set, using default.");
92+
}
93+
94+
// Validate service account credentials
95+
if (!serviceAccount.client_email || !serviceAccount.private_key) {
96+
console.error("Service account validation failed:", {
97+
hasClientEmail: !!serviceAccount.client_email,
98+
hasPrivateKey: !!serviceAccount.private_key,
99+
privateKeyLength: serviceAccount.private_key?.length,
100+
privateKeyPreview: serviceAccount.private_key?.substring(0, 50),
101+
});
102+
throw new Error(
103+
"Missing GOOGLE_CLIENT_EMAIL or GOOGLE_PRIVATE_KEY environment variables."
104+
);
105+
}
106+
107+
// Validate private key format
108+
if (!serviceAccount.private_key.includes("BEGIN PRIVATE KEY") &&
109+
!serviceAccount.private_key.includes("BEGIN RSA PRIVATE KEY")) {
110+
console.error("Private key format validation failed:", {
111+
keyPreview: serviceAccount.private_key.substring(0, 100),
112+
});
113+
throw new Error("Invalid private key format. Key must be in PEM format.");
114+
}
115+
116+
// Debug log for key format (safely)
117+
const keyLines = serviceAccount.private_key.split('\n');
118+
console.log('Private key format check:', {
119+
lineCount: keyLines.length,
120+
hasHeader: keyLines[0]?.includes('BEGIN PRIVATE KEY'),
121+
hasFooter: keyLines[keyLines.length - 1]?.includes('END PRIVATE KEY'),
122+
firstLineLength: keyLines[0]?.length,
123+
secondLineLength: keyLines[1]?.length, // Should be body content
124+
});
125+
126+
try {
127+
return new BetaAnalyticsDataClient({
128+
credentials: {
129+
client_email: serviceAccount.client_email,
130+
private_key: serviceAccount.private_key,
131+
},
132+
projectId: serviceAccount.project_id,
133+
});
134+
} catch (error) {
135+
console.error("Failed to initialize AnalyticsDataClient:", error);
136+
throw new Error(
137+
`Failed to initialize Google Analytics client: ${error instanceof Error ? error.message : "Unknown error"}`
138+
);
139+
}
140+
}
141+
142+
export const dynamic = "force-dynamic";
143+
144+
export async function GET(request: NextRequest) {
145+
try {
146+
console.log("Analytics Route Handler Called");
147+
const { searchParams } = new URL(request.url);
148+
149+
// Health check for debugging deployment
150+
if (searchParams.get("test") === "true") {
151+
return NextResponse.json({
152+
status: "ok",
153+
message: "Analytics route is reachable",
154+
hasServiceAccount: !!serviceAccount,
155+
hasClientEmail: !!serviceAccount?.client_email
156+
});
157+
}
158+
159+
const pagePath = searchParams.get("path");
160+
const startDateParam = searchParams.get("startDate");
161+
const endDateParam = searchParams.get("endDate");
162+
163+
if (!pagePath) {
164+
return NextResponse.json(
165+
{ error: "Query parameter 'path' is required" },
166+
{ status: 400 }
167+
);
168+
}
169+
170+
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
171+
let startDate: string;
172+
let endDate: string;
173+
174+
if (startDateParam || endDateParam) {
175+
if (!startDateParam || !endDateParam) {
176+
return NextResponse.json(
177+
{ error: "Both 'startDate' and 'endDate' are required" },
178+
{ status: 400 }
179+
);
180+
}
181+
182+
if (!dateRegex.test(startDateParam) || !dateRegex.test(endDateParam)) {
183+
return NextResponse.json(
184+
{ error: "Dates must be in YYYY-MM-DD format" },
185+
{ status: 400 }
186+
);
187+
}
188+
189+
startDate = startDateParam;
190+
endDate = endDateParam;
191+
} else {
192+
const now = new Date();
193+
const end = now.toISOString().slice(0, 10);
194+
// Default to start date of 25th December 2024
195+
startDate = "2024-12-25";
196+
endDate = end;
197+
}
198+
199+
const analyticsDataClient = getAnalyticsClient();
200+
const [report] = await analyticsDataClient.runReport({
201+
property: `properties/${PROPERTY_ID}`,
202+
//dimensions: [{ name: "pagePath" }],
203+
dimensions: [
204+
{ name: "pagePath" },
205+
{ name: "eventName" },
206+
],
207+
// metrics: [{ name: "screenPageViews" }],
208+
metrics: [{ name: "eventCount" }],
209+
210+
dimensionFilter: {
211+
andGroup: {
212+
expressions: [
213+
{
214+
filter: {
215+
fieldName: "pagePath",
216+
stringFilter: { matchType: "EXACT", value: pagePath },
217+
},
218+
},
219+
{
220+
filter: {
221+
fieldName: "eventName",
222+
stringFilter: { matchType: "EXACT", value: "page_view" },
223+
},
224+
},
225+
],
226+
},
227+
},
228+
229+
// dimensionFilter: {
230+
// filter: {
231+
// fieldName: "pagePath",
232+
// stringFilter: { matchType: "EXACT", value: pagePath },
233+
// },
234+
// },
235+
dateRanges: [{ startDate, endDate }],
236+
limit: 1,
237+
});
238+
239+
const rawValue = report.rows?.[0]?.metricValues?.[0]?.value ?? "0";
240+
const pageViews = Number.parseInt(rawValue, 10) || 0;
241+
242+
return NextResponse.json({ pageViews });
243+
} catch (error) {
244+
let message = "Failed to fetch page views";
245+
246+
if (error instanceof Error) {
247+
message = error.message;
248+
249+
// Provide more helpful error messages for common issues
250+
if (error.message.includes("DECODER") || error.message.includes("unsupported")) {
251+
message = "Authentication error: Invalid service account credentials. Please check your GOOGLE_PRIVATE_KEY format.";
252+
} else if (error.message.includes("PERMISSION_DENIED")) {
253+
message = "Permission denied: Service account does not have access to this Google Analytics property.";
254+
} else if (error.message.includes("UNAUTHENTICATED")) {
255+
message = "Authentication failed: Invalid service account credentials.";
256+
}
257+
}
258+
259+
console.error("GA4 pageviews API error:", {
260+
message: error instanceof Error ? error.message : String(error),
261+
stack: error instanceof Error ? error.stack : undefined,
262+
errorType: error?.constructor?.name,
263+
});
264+
265+
return NextResponse.json({ error: message }, { status: 500 });
266+
}
267+
}

apps/learner-web-app/src/app/cmslink/page.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,17 @@ export default function CmsLinkPage() {
121121
router.push('/');
122122
return;
123123
}
124-
125-
const targetUrl =
126-
contentType === 'course'
127-
? `/content-details/${identifier}?activeLink=/courses-contents`
128-
: `/player/${identifier}?activeLink=/courses-contents?tab=1`;
124+
const landingPage =
125+
typeof window !== "undefined"
126+
? localStorage.getItem("landingPage")
127+
: "";
128+
129+
const targetUrl =
130+
contentType === "course"
131+
? `/content-details/${identifier}?activeLink=${landingPage}`
132+
: `/player/${identifier}?activeLink=${landingPage}&tab=1`;
133+
134+
129135

130136
if (targetUrl) {
131137
router.push(targetUrl);

apps/learner-web-app/src/components/pos/Home.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ const rangeLength = contentLanguageField?.range?.length || 0;
249249
color: '#FDBE16',
250250
// position: 'relative',
251251
zIndex: 1000,
252+
mt: { xs: 4, md: 0 },
252253
'@media (min-width: 900px)': {
253254
//marginLeft: '-120px',
254255
},

0 commit comments

Comments
 (0)