Return

All tests / src/pages/api analytics.ts

92.68% Statements 38/41
87.5% Branches 14/16
100% Functions 3/3
92.3% Lines 36/39

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 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146    1x 1x 1x 1x                                                       1x   1x 1x   1x                 1x 8x 8x                           1x           9x                           11x   11x 1x     10x     10x 1x 1x       9x                   9x 9x               9x 7x   7x       3x 2x 2x       1x 1x     4x 4x         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';
import type { AnalyticsData } from '../../types/api';
 
/**
 * @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
 *
 * SDD-L07: this route declared `pageViews: string | number` and returned
 * `row.metricValues?.[0]?.value || '0'` — a **string**, because that is what the GA4 Data API emits
 * for every metric. `types/api.ts` declared the same field `number`, and `useAnalytics` cast the
 * response with `as AnalyticsData`, which erases at compile time and checks nothing. So a string
 * travelled all the way to `ViewCounter`, which rendered `12345` unseparated while `CryptoPrice`
 * beside it went through `formatNumber`. The two counters disagreed for exactly this reason.
 *
 * The union is gone and the coercion happens here, at the boundary that knows GA4 sends strings,
 * rather than being carried through the app as an ambiguity. `types/api.ts` is the single contract.
 */
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 };
 
/**
 * @description Turn a GA4 metric value into the number the contract promises.
 *
 * Deliberately not `z.coerce.number()`: coercion there runs `Number()`, which maps `null` and `''`
 * to `0` and an unparseable string to `NaN` — and `NaN` serialises to JSON `null`, so a bad metric
 * would arrive at the client as a missing field rather than a rejected one. Explicit is better here.
 */
const toCount = (value: string | null | undefined): number => {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : 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: toCount(row.metricValues?.[0]?.value),
            newUsers: toCount(row.metricValues?.[1]?.value),
        });
    } catch (err: unknown) {
        console.error('Analytics API Error:', err);
        res.setHeader('Cache-Control', CACHE.error);
        return res.status(500).json({ error: 'Internal server error' });
    }
});