Return

All tests / src/pages/api analytics.ts

92.1% Statements 35/38
77.77% Branches 14/18
100% Functions 2/2
91.66% Lines 33/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128    1x 1x 1x 1x                                           1x   1x 1x   1x                         1x           8x                           10x   10x 1x     9x     9x 1x 1x       8x                   8x 8x               8x 6x   6x       3x 2x 2x       1x 1x     3x 3x         2x 2x 2x      
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { BetaAnalyticsDataClient } from '@google-analytics/data';
import allowCors from '../../helpers/cors';
import { isSafePagePath } from '../../helpers/slug';
import { CACHE } from '@/helpers/http';
 
/**
 * @description This function is used to get the total number of page views for a given page. It uses the Google
 * Analytics Data API to get the data.
 *
 * @param {NextApiRequest} req
 * @param {NextApiResponse<Data>} res
 * @returns {Promise<{
 *     error?: string;
 *     total?: number;
 * }>}
 * @throws {Error: Error while parsing analytics data}
 * @see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
 */
interface AnalyticsData {
    pageViews: string | number;
    newUsers: string | number;
}
 
type AnalyticsResponse = AnalyticsData | { error: string };
 
const GA_PROPERTY = 'properties/348472560';
// Since the property started collecting, so the counters read as all-time totals.
const GA_DATE_RANGES = [{ startDate: '2023-01-01', endDate: 'today' }];
const GA_METRICS = [{ name: 'screenPageViews' }, { name: 'newUsers' }];
 
const EMPTY_ANALYTICS: AnalyticsData = { pageViews: 0, newUsers: 0 };
 
/**
 * The GA4 Data API bills every call against a per-property daily token quota, and
 * without this header each visitor rendering a view counter spent one. A total
 * that is five minutes stale is indistinguishable from a fresh one to a reader,
 * so the edge answers instead — and `stale-while-revalidate` means a quota
 * exhaustion or an API outage shows the last known figure rather than an error.
 *
 * SDD-L02 moved the value itself into helpers/http.ts. This route was the only one that had a cache
 * header; the other seven now share that module, and a local copy here would be the start of the
 * next drift.
 */
const CACHE_CONTROL = CACHE.analytics;
 
/**
 * @description Build the runReport request. Without a slug the report covers the whole
 * property; with one it is filtered down to that exact page path.
 */
const buildReportRequest = (slug?: string) => ({
    property: GA_PROPERTY,
    dateRanges: GA_DATE_RANGES,
    metrics: GA_METRICS,
    ...(slug && {
        dimensionFilter: {
            filter: {
                fieldName: 'pagePath',
                stringFilter: { matchType: 'EXACT' as const, value: slug },
            },
        },
    }),
});
 
export default allowCors(async function handler(req: NextApiRequest, res: NextApiResponse<AnalyticsResponse>) {
    // Only allow GET requests
    if (req.method !== 'GET') {
        return res.status(405).json({ error: 'Method not allowed' });
    }
 
    const { slug } = req.query;
 
    // Validate slug parameter if provided
    if (slug && !isSafePagePath(slug)) {
        res.setHeader('Cache-Control', CACHE.error);
        return res.status(400).json({ error: 'Invalid slug parameter' });
    }
 
    // Validate required environment variables
    Iif (
        !process.env.ANALYTICS_CLIENT_EMAIL ||
        !process.env.ANALYTICS_PRIVATE_KEY ||
        !process.env.ANALYTICS_PROJECT_ID
    ) {
        console.error('Missing required environment variables for Google Analytics API');
        res.setHeader('Cache-Control', CACHE.error);
        return res.status(500).json({ error: 'Configuration error' });
    }
 
    try {
        const analyticsDataClient = new BetaAnalyticsDataClient({
            credentials: {
                client_email: process.env.ANALYTICS_CLIENT_EMAIL,
                private_key: process.env.ANALYTICS_PRIVATE_KEY?.replace(/\\n/g, '\n'),
            },
            projectId: process.env.ANALYTICS_PROJECT_ID,
        });
 
        const [response] = await analyticsDataClient.runReport(buildReportRequest(slug as string | undefined));
        const [row] = response?.rows ?? [];
 
        if (!row) {
            // A page nobody has visited yet is a normal result, not a failure — GA simply
            // returns no rows for it. Only the unfiltered site-wide report having no rows
            // means something is actually wrong.
            if (slug) {
                res.setHeader('Cache-Control', CACHE_CONTROL);
                return res.status(200).json(EMPTY_ANALYTICS);
            }
            // Errors stay uncached: caching one would keep serving it for five minutes
            // after the cause is gone.
            res.setHeader('Cache-Control', CACHE.error);
            return res.status(500).json({ error: 'No data' });
        }
 
        res.setHeader('Cache-Control', CACHE_CONTROL);
        return res.status(200).json({
            pageViews: row.metricValues?.[0]?.value || '0',
            newUsers: row.metricValues?.[1]?.value || '0',
        });
    } catch (err: unknown) {
        console.error('Analytics API Error:', err);
        res.setHeader('Cache-Control', CACHE.error);
        return res.status(500).json({ error: 'Internal server error' });
    }
});