wulf-pulse/app/api/mobile/engagement/trend/route.ts
lorentz 672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
2026-07-18 06:34:57 -04:00

97 lines
3.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
import { getUserTimezone } from '@/lib/services/user-timezone';
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> {
const { session, error: authError } = await requireAuth();
if (authError) return authError;
const tz = getUserTimezone(session);
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).
// 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.
const sql = `
WITH day_series AS (
SELECT generate_series(
((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date,
(NOW() AT TIME ZONE $1)::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::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
AND te.entry_date::date <= (NOW() AT TIME ZONE $1)::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, [tz]);
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 },
);
}
}