chore: merge executor worktree (07-01)
This commit is contained in:
commit
00d0102168
2 changed files with 243 additions and 0 deletions
151
app/api/mobile/engagement/summary/route.ts
Normal file
151
app/api/mobile/engagement/summary/route.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
|
||||
|
||||
export interface MobileEngagementSummary {
|
||||
configured: boolean;
|
||||
activeUsers: number;
|
||||
totalGraphHours: number;
|
||||
totalAutotaskHours: number;
|
||||
hoursPerActiveUser: number;
|
||||
}
|
||||
|
||||
const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const;
|
||||
type AllowedPeriod = (typeof ALLOWED_PERIODS)[number];
|
||||
|
||||
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 { 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 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the latest snapshot date for this period
|
||||
const latestResult = await postgresClient.query(
|
||||
`SELECT MAX(period_end) AS latest_date FROM engagement_snapshots WHERE period_type = $1`,
|
||||
[period],
|
||||
);
|
||||
const latestDate = latestResult.rows[0]?.latest_date;
|
||||
|
||||
if (!latestDate) {
|
||||
return NextResponse.json({
|
||||
configured: isMsgraphConfigured(),
|
||||
activeUsers: 0,
|
||||
totalGraphHours: 0,
|
||||
totalAutotaskHours: 0,
|
||||
hoursPerActiveUser: 0,
|
||||
} satisfies MobileEngagementSummary);
|
||||
}
|
||||
|
||||
const intervalMap: Record<AllowedPeriod, string> = {
|
||||
D7: '7 days',
|
||||
D30: '30 days',
|
||||
D90: '90 days',
|
||||
};
|
||||
const interval = intervalMap[period];
|
||||
|
||||
// Exclude pure-outbound service/automation accounts — copy verbatim from
|
||||
// app/api/engagement/summary/route.ts:42-49
|
||||
const notAutomatedFilter = `NOT (
|
||||
es.user_email IS NOT NULL
|
||||
AND COALESCE(es.emails_received, 0) = 0
|
||||
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||
AND COALESCE(es.teams_calls, 0) = 0
|
||||
)`;
|
||||
|
||||
// Active users — count distinct users with any Teams/email activity
|
||||
const activeResult = await postgresClient.query(
|
||||
`SELECT COUNT(DISTINCT es.user_email) AS count
|
||||
FROM engagement_snapshots es
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
WHERE es.period_type = $1 AND es.period_end = $2
|
||||
AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0)
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate],
|
||||
);
|
||||
const activeUsers = parseInt(activeResult.rows[0]?.count ?? '0', 10);
|
||||
|
||||
// Total Graph hours — sum(audio_duration_seconds + meeting_duration_seconds) / 3600
|
||||
const graphHoursResult = await postgresClient.query(
|
||||
`SELECT COALESCE(SUM(
|
||||
COALESCE(es.audio_duration_seconds, 0) + COALESCE(es.meeting_duration_seconds, 0)
|
||||
), 0) AS total_seconds
|
||||
FROM engagement_snapshots es
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
WHERE es.period_type = $1 AND es.period_end = $2
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate],
|
||||
);
|
||||
const totalGraphSeconds = parseFloat(graphHoursResult.rows[0]?.total_seconds ?? '0');
|
||||
const totalGraphHours = Math.round((totalGraphSeconds / 3600) * 10) / 10;
|
||||
|
||||
// Total Autotask hours — SUM(time_entries.hours_worked) for matched resources in the period.
|
||||
// NOTE: ${interval} is interpolated, NOT parameterized — safe because period is whitelisted
|
||||
// to one of ['D7','D30','D90'] and interval is looked up from a static map (not user input).
|
||||
const atHoursResult = await postgresClient.query(
|
||||
`SELECT COALESCE(SUM(te.hours_worked), 0) AS total_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 >= NOW() - INTERVAL '${interval}'
|
||||
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#%'`,
|
||||
);
|
||||
const totalAutotaskHours = Math.round(parseFloat(atHoursResult.rows[0]?.total_hours ?? '0') * 10) / 10;
|
||||
|
||||
// Hours per active user — 0 when activeUsers === 0 (UI renders "—")
|
||||
const hoursPerActiveUser =
|
||||
activeUsers === 0 ? 0 : Math.round((totalAutotaskHours / activeUsers) * 10) / 10;
|
||||
|
||||
return NextResponse.json({
|
||||
configured: isMsgraphConfigured(),
|
||||
activeUsers,
|
||||
totalGraphHours,
|
||||
totalAutotaskHours,
|
||||
hoursPerActiveUser,
|
||||
} satisfies MobileEngagementSummary);
|
||||
} catch (error) {
|
||||
console.error('GET /api/mobile/engagement/summary failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch engagement summary',
|
||||
message: error instanceof Error ? error.message : 'unknown',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
92
app/api/mobile/engagement/trend/route.ts
Normal file
92
app/api/mobile/engagement/trend/route.ts
Normal file
|
|
@ -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<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 { 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue