diff --git a/app/api/mobile/engagement/trend/route.ts b/app/api/mobile/engagement/trend/route.ts new file mode 100644 index 0000000..653ccd0 --- /dev/null +++ b/app/api/mobile/engagement/trend/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export interface SparklinePoint { + date: string; // ISO date string "YYYY-MM-DD" + hours: number; // total Autotask hours for that day (0 if no entries) +} + +export interface EngagementTrendResponse { + points: SparklinePoint[]; // D7 → 7, D30 → 30, D90 → 90 points +} + +const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const; +type AllowedPeriod = (typeof ALLOWED_PERIODS)[number]; +const PERIOD_DAYS: Record = { D7: 7, D30: 30, D90: 90 }; + +function parsePeriod(raw: string | null): AllowedPeriod | null { + if (!raw) return 'D30'; // default per D-04 + return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null; +} + +export async function GET(request: NextRequest): Promise { + const { error: authError } = await requireAuth(); + if (authError) return authError; + + const { searchParams } = request.nextUrl; + const period = parsePeriod(searchParams.get('period')); + if (period === null) { + return NextResponse.json( + { error: 'Invalid period', message: "period must be one of 'D7', 'D30', 'D90'" }, + { status: 400 }, + ); + } + + const days = PERIOD_DAYS[period]; + // T-07-03 mitigation: period whitelist bounds the date range to max 90 days. + // No user-supplied row-count parameter — generate_series produces at most 90 rows. + + try { + // generate_series ensures every day in the range has a row even when zero hours + // were logged (D-15: continuous series, no gaps in the sparkline). + // ${days - 1} is interpolated, NOT parameterized — safe because period is whitelisted + // to one of ['D7','D30','D90'] and days comes from a static map (not user input). + const sql = ` + WITH day_series AS ( + SELECT generate_series( + (CURRENT_DATE - INTERVAL '${days - 1} days')::date, + CURRENT_DATE, + INTERVAL '1 day' + )::date AS day + ), + daily_hours AS ( + SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours + FROM time_entries te + JOIN resources r ON r.id = te.resource_id + AND (r.is_deleted = false OR r.is_deleted IS NULL) + JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email) + WHERE te.entry_date >= CURRENT_DATE - INTERVAL '${days - 1} days' + AND te.entry_date <= CURRENT_DATE + AND (te.is_deleted = false OR te.is_deleted IS NULL) + AND gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + GROUP BY te.entry_date::date + ) + SELECT to_char(ds.day, 'YYYY-MM-DD') AS date, + COALESCE(dh.hours, 0)::numeric AS hours + FROM day_series ds + LEFT JOIN daily_hours dh ON dh.day = ds.day + ORDER BY ds.day ASC + `; + + const result = await postgresClient.query(sql); + + const points: SparklinePoint[] = result.rows.map((row) => ({ + date: String(row.date), + hours: Math.round(parseFloat(row.hours ?? '0') * 10) / 10, + })); + + return NextResponse.json({ points } satisfies EngagementTrendResponse); + } catch (error) { + console.error('GET /api/mobile/engagement/trend failed:', error); + return NextResponse.json( + { + error: 'Failed to fetch engagement trend', + message: error instanceof Error ? error.message : 'unknown', + }, + { status: 500 }, + ); + } +}