2026-05-03 22:44:10 -04:00
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
|
|
|
import { requireAuth } from '@/lib/auth-utils';
|
|
|
|
|
import { postgresClient } from '@/lib/services/postgres-client';
|
2026-05-07 08:04:47 -04:00
|
|
|
import { getUserTimezone } from '@/lib/services/user-timezone';
|
2026-05-03 22:44:10 -04:00
|
|
|
|
|
|
|
|
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<AllowedPeriod, number> = { 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<NextResponse> {
|
2026-05-07 08:04:47 -04:00
|
|
|
const { session, error: authError } = await requireAuth();
|
2026-05-03 22:44:10 -04:00
|
|
|
if (authError) return authError;
|
2026-05-07 08:04:47 -04:00
|
|
|
const tz = getUserTimezone(session);
|
2026-05-03 22:44:10 -04:00
|
|
|
|
|
|
|
|
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).
|
2026-05-07 08:04:47 -04:00
|
|
|
// TZ-02 (Phase 7.1): day buckets are aligned to the calling user's
|
|
|
|
|
// IANA timezone via $1 (validated by getUserTimezone). Storage tz
|
|
|
|
|
// for `time_entries.entry_date` remains UTC.
|
2026-05-03 22:44:10 -04:00
|
|
|
const sql = `
|
|
|
|
|
WITH day_series AS (
|
|
|
|
|
SELECT generate_series(
|
2026-05-14 21:58:35 -04:00
|
|
|
((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date,
|
|
|
|
|
(NOW() AT TIME ZONE $1)::date,
|
2026-05-03 22:44:10 -04:00
|
|
|
INTERVAL '1 day'
|
|
|
|
|
)::date AS day
|
|
|
|
|
),
|
|
|
|
|
daily_hours AS (
|
2026-07-18 06:34:57 -04:00
|
|
|
SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours
|
2026-05-03 22:44:10 -04:00
|
|
|
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)
|
2026-07-18 06:34:57 -04:00
|
|
|
WHERE te.entry_date::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
|
|
|
|
|
AND te.entry_date::date <= (NOW() AT TIME ZONE $1)::date
|
2026-05-03 22:44:10 -04:00
|
|
|
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#%'
|
2026-07-18 06:34:57 -04:00
|
|
|
GROUP BY te.entry_date::date
|
2026-05-03 22:44:10 -04:00
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
`;
|
|
|
|
|
|
2026-05-07 08:04:47 -04:00
|
|
|
const result = await postgresClient.query(sql, [tz]);
|
2026-05-03 22:44:10 -04:00
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|