30 KiB
30 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-engagement-overview-new | 01 | execute | 1 |
|
true |
|
|
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, exportsMobileEngagementSummaryapp/api/mobile/engagement/trend/route.ts— GET handler, exportsSparklinePointandEngagementTrendResponse
<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> |
<success_criteria>
- Both files written, both export the documented interfaces
npx tsc --noEmit --prettyexits 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'andimport type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route'without errors </success_criteria>