wulf-pulse/.planning/phases/07-engagement-overview-new/07-01-PLAN.md

30 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
07-engagement-overview-new 01 execute 1
app/api/mobile/engagement/summary/route.ts
app/api/mobile/engagement/trend/route.ts
true
ENG-03
ENG-05
truths artifacts key_links
GET /api/mobile/engagement/summary?period=D30 returns 200 with { configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser } when authed
GET /api/mobile/engagement/trend?period=D30 returns 200 with { points: [{ date, hours }] } where points.length === 30
Both endpoints reject unauth requests via requireAuth() (401/redirect)
Both endpoints reject period values outside ['D7','D30','D90'] with 400
Endpoints export TypeScript interfaces (MobileEngagementSummary, EngagementTrendResponse, SparklinePoint) consumable via `import type`
When MSGRAPH not configured, summary returns configured: false with zeroed totals (does not throw)
path provides exports
app/api/mobile/engagement/summary/route.ts Mobile engagement summary endpoint (4 totals + configured flag)
GET
MobileEngagementSummary
path provides exports
app/api/mobile/engagement/trend/route.ts Mobile engagement daily-hours trend endpoint
GET
SparklinePoint
EngagementTrendResponse
from to via pattern
app/api/mobile/engagement/summary/route.ts lib/auth-utils.ts requireAuth() at handler entry requireAuth(
from to via pattern
app/api/mobile/engagement/summary/route.ts lib/services/msgraph-factory.ts isMsgraphConfigured() isMsgraphConfigured(
from to via pattern
app/api/mobile/engagement/summary/route.ts engagement_snapshots / time_entries postgresClient.query parameterized SQL postgresClient.query
from to via pattern
app/api/mobile/engagement/trend/route.ts time_entries daily aggregate join with graph_users + resources time_entries
Build the two new mobile engagement API endpoints that the page (Plan 03) consumes. The existing `/api/engagement/summary` returns averages, not the totals ENG-03 specifies, so a thin mobile endpoint is required (D-09). No existing trend endpoint exists, so the sparkline (ENG-05) needs `/api/mobile/engagement/trend` (D-14). Reuse the existing `/api/engagement/users` endpoint as-is for the per-employee list (D-16) — no work here.

Purpose: Deliver the two read-only data endpoints with requireAuth(), period whitelist validation, parameterized SQL, manual snake_case → camelCase transform, and exported TypeScript interfaces (mirrors Phase 4/6 API style). Both endpoints follow CLAUDE.md rules: no Zod (D-38), no ORM, NextResponse.json envelopes.

Output:

  • app/api/mobile/engagement/summary/route.ts — GET handler, exports MobileEngagementSummary
  • app/api/mobile/engagement/trend/route.ts — GET handler, exports SparklinePoint and EngagementTrendResponse

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/07-engagement-overview-new/07-CONTEXT.md @.planning/phases/07-engagement-overview-new/07-UI-SPEC.md @CLAUDE.md @app/api/engagement/summary/route.ts @app/api/engagement/users/route.ts @app/api/mobile/analyzer/feed/route.ts @app/api/mobile/tickets/route.ts @migrations/041_create_engagement_tables.sql @migrations/042_add_engagement_calendar_columns.sql @lib/auth-utils.ts @lib/services/msgraph-factory.ts
// In existing app/api/engagement/summary/route.ts (lines 42-49) — copy this filter into the new endpoint:
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
)`;

// Period to interval map (used by both endpoints, matches existing endpoints):
const intervalMap: Record<string, string> = {
  D7: '7 days',
  D30: '30 days',
  D90: '90 days',
};
// requireAuth signature (lib/auth-utils.ts):
export async function requireAuth(): Promise<{ session: Session; error: null } | { session: null; error: NextResponse }>;

// isMsgraphConfigured (lib/services/msgraph-factory.ts):
export function isMsgraphConfigured(): boolean;

// postgresClient (lib/services/postgres-client.ts):
postgresClient.query(sql: string, params?: unknown[]): Promise<{ rows: any[] }>;
// Interfaces this plan MUST export (consumed by 07-03 page):

export interface MobileEngagementSummary {
  configured: boolean;
  activeUsers: number;
  totalGraphHours: number;       // 1 decimal, derived from audio + meeting seconds
  totalAutotaskHours: number;    // 1 decimal, sum of time_entries.hours_worked
  hoursPerActiveUser: number;    // totalAutotaskHours / activeUsers (0 if activeUsers === 0)
}

export interface SparklinePoint {
  date: string;    // "YYYY-MM-DD"
  hours: number;   // total Autotask hours for that day across all matched users (0 if no entries)
}

export interface EngagementTrendResponse {
  points: SparklinePoint[];     // D7→7, D30→30, D90→90 points
}
Task 1: Create /api/mobile/engagement/summary endpoint - app/api/engagement/summary/route.ts (existing desktop summary — pattern reference for SQL filters; DO NOT modify, per D-34) - app/api/mobile/analyzer/feed/route.ts (mobile API style: requireAuth, exported interfaces, NextResponse.json) - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-08, D-09, D-32, D-33, D-38) - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (API Shape Contract section, MobileEngagementSummary) - lib/auth-utils.ts (requireAuth signature) - lib/services/msgraph-factory.ts (isMsgraphConfigured) - migrations/041_create_engagement_tables.sql + migrations/042_add_engagement_calendar_columns.sql (column names: audio_duration_seconds, meeting_duration_seconds, period_type) app/api/mobile/engagement/summary/route.ts - Test 1: GET ?period=D30 (authed) returns 200 + JSON with keys { configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser } - Test 2: Default period (no param) === D30 - Test 3: GET ?period=D7 / D90 also returns 200 with the same shape - Test 4: GET ?period=D1 or ?period=foo returns 400 (whitelist rejection per D-07) - Test 5: When activeUsers === 0, hoursPerActiveUser === 0 (numeric zero, not "—" — UI renders the dash) - Test 6: When MSGRAPH not configured (isMsgraphConfigured() === false), returns 200 with configured: false and zeroed totals (does not throw) - Test 7: Unauth request → handled by requireAuth (returns its NextResponse) Create new file `app/api/mobile/engagement/summary/route.ts`. Mirror the structure of `app/api/mobile/analyzer/feed/route.ts` (Phase 6 reference): imports, exported interface, `requireAuth()` gate, period validation, SQL via `postgresClient.query`, manual snake_case → camelCase transform, NextResponse.json envelope. NO Zod (per D-38, CLAUDE.md).
**Imports (top of file):**
```ts
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';
```

**Exported interface (must match UI-SPEC API Shape Contract verbatim):**
```ts
export interface MobileEngagementSummary {
  configured: boolean;
  activeUsers: number;
  totalGraphHours: number;
  totalAutotaskHours: number;
  hoursPerActiveUser: number;
}
```

**Period whitelist (D-07, D-32, security threat T-07-04):**
```ts
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;
}
```

**GET handler shape (per D-09, D-32, D-33):**
```ts
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 {
    // 1. Latest snapshot date for this period (mirrors existing /api/engagement/summary)
    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];

    // notAutomatedFilter — 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
    )`;

    // 2. activeUsers — match the existing summary endpoint's "active this period" query
    // (count of distinct users with teams meetings/messages/emails 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);

    // 3. totalGraphHours — sum(audio_duration_seconds + meeting_duration_seconds) / 3600 for matched users
    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;

    // 4. totalAutotaskHours — SUM(time_entries.hours_worked) for matched human resources in interval
    // NOTE: ${interval} is interpolated NOT parameterized. Safe because period is whitelisted above.
    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;

    // 5. hoursPerActiveUser — totalAutotaskHours / activeUsers (0 if 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 },
    );
  }
}
```

**Per D-09:** This endpoint exists because the existing `/api/engagement/summary` returns averages (avgHoursWorked, avgTeamsMeetings, etc.), not the four totals ENG-03 specifies. We reuse the SAME SQL filters/joins (notAutomatedFilter, account_enabled + email scoping, resources DISTINCT ON dedupe) for consistency.

**Per D-32:** `requireAuth()` gates every request. **Do not call any DB code before this gate.**

**Per D-33:** Document inherited risk for the existing `/api/engagement/users` endpoint (which lacks `requireAuth()`) in the threat model — but DO NOT modify the desktop endpoint (out of scope per D-34 and PROJECT.md "Restyling or replacing the desktop pages…").

**Per D-36:** Read-only consumption — no edits to `lib/services/msgraph-*` or `lib/services/engagement-sync-service.ts`.

**Per D-38 / CLAUDE.md:** No Zod. The whitelist function above is sufficient input validation.
npx tsc --noEmit --pretty 2>&1 | tee /tmp/p07-01-task1-tsc.log; grep -E "app/api/mobile/engagement/summary" /tmp/p07-01-task1-tsc.log && exit 1; exit 0 - File exists: `test -f app/api/mobile/engagement/summary/route.ts` - Has `requireAuth` import: `grep -q "from '@/lib/auth-utils'" app/api/mobile/engagement/summary/route.ts` - Calls `requireAuth()` BEFORE any `postgresClient.query`: `awk '/postgresClient\.query|requireAuth\(\)/' app/api/mobile/engagement/summary/route.ts | head -1 | grep -q 'requireAuth()'` - Exports the interface: `grep -q "export interface MobileEngagementSummary" app/api/mobile/engagement/summary/route.ts` - All 5 fields present in interface: `grep -E "configured|activeUsers|totalGraphHours|totalAutotaskHours|hoursPerActiveUser" app/api/mobile/engagement/summary/route.ts | wc -l` ≥ 5 - Period whitelist present: `grep -E "D7.*D30.*D90|'D7'|'D30'|'D90'" app/api/mobile/engagement/summary/route.ts` matches AND a 400 response path exists: `grep -q "status: 400" app/api/mobile/engagement/summary/route.ts` - Calls `isMsgraphConfigured()`: `grep -q "isMsgraphConfigured()" app/api/mobile/engagement/summary/route.ts` - NO Zod (D-38): `! grep -q "from 'zod'" app/api/mobile/engagement/summary/route.ts` - NO ORM (CLAUDE.md): `! grep -q "prisma\|drizzle" app/api/mobile/engagement/summary/route.ts` - SQL params parameterized (period passed as $1, not interpolated): `grep -E '\$1.*\$2' app/api/mobile/engagement/summary/route.ts` - `npx tsc --noEmit --pretty` exits 0 (no type errors) npx tsc --noEmit --pretty Endpoint file created. `npx tsc --noEmit --pretty` exits 0. Hitting GET /api/mobile/engagement/summary?period=D30 (when authed) returns the 5-field MobileEngagementSummary JSON. Invalid period returns 400. Task 2: Create /api/mobile/engagement/trend endpoint - app/api/mobile/engagement/summary/route.ts (sibling — created in Task 1; reuse the same period whitelist + notAutomatedFilter pattern) - app/api/engagement/users/route.ts (existing endpoint — pattern reference for time_entries + resources + graph_users join; DO NOT modify) - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-11, D-12, D-14, D-32, D-38) - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Sparkline section: D7→7 points, D30→30 points, D90→90 points; missing days = 0) - migrations/041_create_engagement_tables.sql (graph_users + engagement_snapshots schema) app/api/mobile/engagement/trend/route.ts - Test 1: GET ?period=D7 (authed) returns 200 + { points: [...] } where points.length === 7 - Test 2: GET ?period=D30 (authed) returns 200 with points.length === 30 - Test 3: GET ?period=D90 (authed) returns 200 with points.length === 90 - Test 4: GET ?period=foo returns 400 (whitelist rejection per D-07; same pattern as Task 1) - Test 5: Each point has shape { date: "YYYY-MM-DD", hours: number }; days with no time_entries return hours: 0 (NOT omitted — UI-SPEC requires continuous series) - Test 6: Days are in ascending date order (oldest first → most recent last so the SVG renders left-to-right with most recent on the right) - Test 7: Total payload size capped at 90 days max — no unbounded result set (T-07-03 mitigation) - Test 8: Unauth request → handled by requireAuth Create new file `app/api/mobile/engagement/trend/route.ts`. Same structural pattern as Task 1: requireAuth gate first, period whitelist validation second, parameterized SQL third, manual transform last. NO Zod (D-38).
**Imports:**
```ts
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
```

**Exported interfaces (must match UI-SPEC API Shape Contract):**
```ts
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
}
```

**Period whitelist (same as Task 1, copy):**
```ts
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';
  return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null;
}
```

**GET handler — daily aggregate query:**

The query aggregates `time_entries.hours_worked` per `entry_date` for human resources matching `graph_users` in the period. Unlike Task 1's totals, this produces one row per day so the sparkline can render a continuous line.

```ts
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: bounded by whitelisted period (max 90 days). No user-supplied row cap.

  try {
    // generate_series produces one row per day so days with zero hours are still represented (D-15: continuous line, no gaps).
    // ${days} is interpolated NOT parameterized — safe because period is whitelisted to 7/30/90.
    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 },
    );
  }
}
```

**Per D-12 / D-15:** Continuous series — `generate_series` ensures every day in the period has a row even when zero hours were logged (no gaps). The UI sparkline draws zero-hour days down to baseline, never breaks the line.

**Per D-32:** `requireAuth()` is the first call — DB queries only run after auth.

**Per D-38:** No Zod. Whitelist sufficient.

**Per D-34, D-36:** No edits to existing engagement endpoints, services, or migrations.

**Per security threat T-07-03 (rate-limiting/DoS):** Period whitelist caps the date range to ≤90 days; the query has bounded results (one row per day, max 90). No user-supplied row-count parameter.
npx tsc --noEmit --pretty 2>&1 | tee /tmp/p07-01-task2-tsc.log; grep -E "app/api/mobile/engagement/trend" /tmp/p07-01-task2-tsc.log && exit 1; exit 0 - File exists: `test -f app/api/mobile/engagement/trend/route.ts` - Has `requireAuth` import + call BEFORE DB query: `awk '/postgresClient\.query|requireAuth\(\)/' app/api/mobile/engagement/trend/route.ts | head -1 | grep -q 'requireAuth()'` - Exports both interfaces: `grep -q "export interface SparklinePoint" app/api/mobile/engagement/trend/route.ts && grep -q "export interface EngagementTrendResponse" app/api/mobile/engagement/trend/route.ts` - Period whitelist present + 400 path: `grep -q "'D7', 'D30', 'D90'" app/api/mobile/engagement/trend/route.ts && grep -q "status: 400" app/api/mobile/engagement/trend/route.ts` - Uses `generate_series` to ensure continuous days (D-15): `grep -q "generate_series" app/api/mobile/engagement/trend/route.ts` - SQL is bounded by whitelisted period (no user-supplied days param): `! grep -E "searchParams.get\('days'\)|searchParams.get\(\"days\"\)" app/api/mobile/engagement/trend/route.ts` - NO Zod: `! grep -q "from 'zod'" app/api/mobile/engagement/trend/route.ts` - `npx tsc --noEmit --pretty` exits 0 (no type errors) npx tsc --noEmit --pretty Endpoint file created. `npx tsc --noEmit --pretty` exits 0. GET /api/mobile/engagement/trend?period=D30 (authed) returns { points: SparklinePoint[] } with exactly 30 points in ascending date order. Invalid period returns 400.

<threat_model>

Trust Boundaries

Boundary Description
Browser → /api/mobile/engagement/* Authenticated mobile clients send GET requests with cookie-based session; period query param is untrusted input
Mobile API → Postgres Endpoints query engagement_snapshots, graph_users, resources, time_entries; SQL parameters supplied by handler (period whitelisted)
Mobile API → MSGraph factory Read-only isMsgraphConfigured() boolean check; no credential exposure

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-01 Spoofing/AuthN summary + trend GET handlers mitigate Both handlers call requireAuth() from lib/auth-utils.ts as the FIRST statement; if authError is non-null, return immediately. Acceptance criteria check enforces ordering via awk grep.
T-07-02 Tampering / Injection period query parameter mitigate Whitelist ['D7','D30','D90'] via parsePeriod(). Invalid values return 400 BEFORE any SQL executes. The whitelisted period is then used both as a bound parameter ($1) and to look up a static INTERVAL '7 days' / '30 days' / '90 days' constant — interpolation is on a fixed map value, not user input.
T-07-03 DoS / Resource exhaustion trend endpoint daily aggregation mitigate Whitelist caps the date range to max 90 days. generate_series produces at most 90 rows. No user-supplied row-count or page-size parameter. SQL aggregation runs against indexed columns (entry_date, resource_id per existing engagement endpoints — no new index required).
T-07-04 Information Disclosure summary/trend response payloads mitigate Both endpoints return ONLY scalars (totals, hours-per-day) — no per-user PII (no display_name, no email, no jobTitle). Sensitive metadata (teams_chat_messages, emails_sent, etc.) is summed, never enumerated.
T-07-05 Information Disclosure (inherited) existing /api/engagement/users (NOT modified by this plan) accept Existing desktop endpoint lacks requireAuth() (CONTEXT.md D-33). Reuse from Plan 03 does not increase exposure since middleware.ts blocks unauth access to /api/* not in the public list (verify on first run). Out of scope per D-34 + PROJECT.md ("Restyling or replacing the desktop pages…"). Document follow-up; recommend a future security phase. Mirrors Phase 6 IDOR T-06P03-02 disposition pattern.
T-07-06 Repudiation Both new endpoints accept Read-only GET endpoints surfacing aggregate metrics. No state mutation, no audit trail required. Standard console.error logging on exception paths.
</threat_model>
- Both endpoint files exist and pass `npx tsc --noEmit --pretty` - `grep -q "export interface" app/api/mobile/engagement/summary/route.ts && grep -q "export interface" app/api/mobile/engagement/trend/route.ts` (interfaces exported for Plan 03 to import) - `requireAuth()` is called BEFORE any `postgresClient.query` in both files (auth gate ordering) - Period whitelist + 400 path present in both files - Manual run-time check: `curl -s -i http://localhost:3100/api/mobile/engagement/summary?period=D30 | head -3` returns 401 or redirect when not authed; returns 200 with 5-field JSON when authed - Manual run-time check: `curl -s 'http://localhost:3100/api/mobile/engagement/trend?period=foo' | head -1` returns 400 (or middleware redirect; behavior depends on session)

<success_criteria>

  • Both files written, both export the documented interfaces
  • npx tsc --noEmit --pretty exits 0
  • Period whitelist enforced (D7/D30/D90 only) on both endpoints
  • requireAuth() is the first statement in each handler
  • No Zod, no ORM, no edits outside the two new files
  • Plan 03 can import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route' and import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route' without errors </success_criteria>
After completion, create `.planning/phases/07-engagement-overview-new/07-01-SUMMARY.md` documenting: - Endpoint paths + exported interfaces - Period whitelist values - Any deviations from UI-SPEC API Shape Contract (none expected) - Confirmed inherited risk for `/api/engagement/users` (per D-33; out-of-scope to fix)