From d746ceeca1e6dddfc58dba5841fe457ae4bb5008 Mon Sep 17 00:00:00 2001 From: Nathan Wang <149958172+nwang783@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:23:36 -0400 Subject: [PATCH] Add activity summary insights --- .gitignore | 3 +- dashboard/src/App.css | 118 ++++++- dashboard/src/components/ActivitySummary.jsx | 65 ++++ dashboard/src/components/GoalBar.test.jsx | 51 ++- dashboard/src/components/InsightCard.jsx | 89 +++++ dashboard/src/components/MemoryPanel.jsx | 3 - dashboard/src/components/PlanDetail.jsx | 20 +- dashboard/src/components/PlanDetail.test.jsx | 70 ++++ dashboard/src/components/UpdateFeed.jsx | 24 +- dashboard/src/components/UpdateFeed.test.jsx | 57 +++- dashboard/src/lib/planTags.js | 12 + firestore.rules | 5 + functions/index.js | 149 +++++++++ functions/insights/activity-summary.cjs | 321 +++++++++++++++++++ functions/package-lock.json | 146 ++++++++- functions/package.json | 6 +- test/activity-summary.test.js | 86 +++++ test/join-flow-api.test.cjs | 13 + 18 files changed, 1205 insertions(+), 33 deletions(-) create mode 100644 dashboard/src/components/ActivitySummary.jsx create mode 100644 dashboard/src/components/InsightCard.jsx create mode 100644 functions/insights/activity-summary.cjs create mode 100644 test/activity-summary.test.js diff --git a/.gitignore b/.gitignore index 97a2045..412ce60 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dashboard/node_modules/ dashboard/dist/ .env +.env.local dashboard/.env .agents .DS_Store @@ -9,4 +10,4 @@ dashboard/.env *.log .firebase/ .dev/ -nomergeconflicts-firebase-adminsdk-fbsvc-1155d26ddc.json \ No newline at end of file +nomergeconflicts-firebase-adminsdk-fbsvc-1155d26ddc.json diff --git a/dashboard/src/App.css b/dashboard/src/App.css index 9a966bb..9605533 100644 --- a/dashboard/src/App.css +++ b/dashboard/src/App.css @@ -327,6 +327,10 @@ body::before { font-weight: 600; color: var(--text); line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; } .goal-card .goal-meta { @@ -979,9 +983,119 @@ button.feed-item { color: var(--text-muted); } -.feed-item .feed-note { +.activity-stats { + display: flex; + gap: 20px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.stat-chip { + font-family: var(--font-display); + font-size: 11px; + color: var(--text-muted); + letter-spacing: 0.3px; +} + +.stat-chip strong { color: var(--text); - margin-left: 4px; + font-weight: 600; +} + +.insight-card { + border: 1px solid var(--border-light); + background: linear-gradient(180deg, rgba(250, 247, 240, 0.96) 0%, rgba(246, 241, 228, 0.92) 100%); + border-radius: var(--radius); + padding: 18px 18px 16px; + margin-bottom: 20px; + box-shadow: var(--shadow-sm); +} + +.insight-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.insight-card__header h3 { + font-family: var(--font-display); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.8px; + text-transform: uppercase; +} + +.insight-card__status { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--accent); + border: 1px solid rgba(232, 93, 38, 0.22); + background: rgba(232, 93, 38, 0.08); + border-radius: 999px; + padding: 3px 8px; +} + +.insight-card__headline { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 10px; +} + +.insight-card__list { + padding-left: 18px; + margin: 0 0 10px; + color: var(--text-muted); + line-height: 1.55; + font-size: 13px; +} + +.insight-card__list--compact { + margin-bottom: 0; +} + +.insight-card__split { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 8px; +} + +.insight-card__group { + min-width: 0; +} + +.insight-card__group-label { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; +} + +.insight-card__meta { + margin-top: 12px; + font-family: var(--font-display); + font-size: 10px; + color: var(--text-muted); + letter-spacing: 0.2px; +} + +.insight-card__loading, +.insight-card__empty, +.insight-card__error { + font-size: 13px; + color: var(--text-muted); + line-height: 1.6; +} + +.insight-card__error { + color: #a33a2c; } .feed-load-more { diff --git a/dashboard/src/components/ActivitySummary.jsx b/dashboard/src/components/ActivitySummary.jsx new file mode 100644 index 0000000..48e2101 --- /dev/null +++ b/dashboard/src/components/ActivitySummary.jsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { doc, onSnapshot } from 'firebase/firestore'; +import { db } from '../firebase.js'; +import { relativeTime, toDate } from '../utils.js'; +import InsightCard from './InsightCard.jsx'; + +const FEATURE_KEY = 'activity-summary'; + +export default function ActivitySummary({ teamId }) { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + const unsub = onSnapshot( + doc(db, 'teams', teamId, 'insights', FEATURE_KEY), + (snap) => { + setSummary(snap.exists() ? snap.data() : null); + setLoading(false); + }, + (err) => { + setError(err.message); + setLoading(false); + }, + ); + return unsub; + }, [teamId]); + + const generatedAt = toDate(summary?.generatedAt || summary?.updatedAt); + const modelLabel = summary?.model || 'google/gemini-3.1-flash-lite-preview'; + const confidence = typeof summary?.confidence === 'number' + ? `${Math.round(summary.confidence * 100)}% confidence` + : null; + const statusLabel = summary?.status === 'error' + ? 'stale' + : summary?.status === 'ready' + ? 'live' + : null; + + const metaParts = []; + if (generatedAt) metaParts.push(`refreshed ${relativeTime(generatedAt)}`); + if (modelLabel) metaParts.push(modelLabel); + if (confidence) metaParts.push(confidence); + if (summary?.sourceWindow?.recentActivityCount != null) { + metaParts.push(`${summary.sourceWindow.recentActivityCount} events`); + } + const meta = metaParts.join(' · '); + + return ( + + ); +} diff --git a/dashboard/src/components/GoalBar.test.jsx b/dashboard/src/components/GoalBar.test.jsx index 81b33f3..593667b 100644 --- a/dashboard/src/components/GoalBar.test.jsx +++ b/dashboard/src/components/GoalBar.test.jsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { vi, describe, it, beforeEach, expect } from 'vitest'; const snapshotHandlers = []; @@ -22,6 +22,7 @@ import GoalBar from './GoalBar.jsx'; describe('GoalBar', () => { beforeEach(() => { snapshotHandlers.length = 0; + cleanup(); }); it('routes the 2-week goal card to the linked canonical plan when alignment matches', async () => { @@ -57,9 +58,55 @@ describe('GoalBar', () => { ], }); - await waitFor(() => expect(screen.getByRole('button', { name: /2-week goal/i })).toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByRole('button', { name: /2-week goal/i })).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: /2-week goal/i })); expect(onSelectPlan).toHaveBeenCalledWith('plan-2w'); }); + + it('prefers the newest created goal-linked plan when multiple matches tie', async () => { + const onSelectPlan = vi.fn(); + render(); + + const twoWeekHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/2week')); + const plansHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/plans')); + + twoWeekHandler.onNext({ + exists: () => true, + data: () => ({ + content: 'Ship the unified company memory timeline and keep reviewer context fresh', + updatedAt: new Date(), + updatedBy: 'agent-admin', + }), + }); + plansHandler.onNext({ + docs: [ + { + id: 'older-plan', + data: () => ({ + slug: 'older-plan', + summary: 'Older summary', + alignment: 'Establishes the 2-week goal by shipping the fastest path to first product', + createdAt: new Date('2026-04-11T10:00:00Z'), + updatedAt: new Date('2026-04-11T10:00:00Z'), + }), + }, + { + id: 'newer-plan', + data: () => ({ + slug: 'newer-plan', + summary: 'Newer summary', + alignment: 'Establishes the 2-week goal by shipping the fastest path to first product', + createdAt: new Date('2026-04-11T10:01:00Z'), + updatedAt: new Date('2026-04-11T10:00:00Z'), + }), + }, + ], + }); + + await waitFor(() => expect(screen.getByRole('button', { name: /2-week goal/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /2-week goal/i })); + + expect(onSelectPlan).toHaveBeenCalledWith('newer-plan'); + }); }); diff --git a/dashboard/src/components/InsightCard.jsx b/dashboard/src/components/InsightCard.jsx new file mode 100644 index 0000000..aa88e18 --- /dev/null +++ b/dashboard/src/components/InsightCard.jsx @@ -0,0 +1,89 @@ +export default function InsightCard({ + title, + statusLabel = null, + loading = false, + error = null, + emptyText = 'No insight available yet.', + headline = null, + bullets = [], + riskFlags = [], + nextActions = [], + meta = null, +}) { + if (loading) { + return ( +
+
+

{title}

+
+
loading summary...
+
+ ); + } + + if (error && !headline) { + return ( +
+
+

{title}

+ {statusLabel && {statusLabel}} +
+
{error}
+
+ ); + } + + if (!headline && bullets.length === 0 && riskFlags.length === 0 && nextActions.length === 0) { + return ( +
+
+

{title}

+ {statusLabel && {statusLabel}} +
+
{emptyText}
+
+ ); + } + + return ( +
+
+

{title}

+ {statusLabel && {statusLabel}} +
+ {headline &&

{headline}

} + {bullets.length > 0 && ( +
    + {bullets.map((bullet, index) => ( +
  • {bullet}
  • + ))} +
+ )} + {(riskFlags.length > 0 || nextActions.length > 0) && ( +
+ {riskFlags.length > 0 && ( +
+
risks
+
    + {riskFlags.map((flag, index) => ( +
  • {flag}
  • + ))} +
+
+ )} + {nextActions.length > 0 && ( +
+
next
+
    + {nextActions.map((action, index) => ( +
  • {action}
  • + ))} +
+
+ )} +
+ )} + {meta &&
{meta}
} +
+ ); +} diff --git a/dashboard/src/components/MemoryPanel.jsx b/dashboard/src/components/MemoryPanel.jsx index 6d8d751..e9f0355 100644 --- a/dashboard/src/components/MemoryPanel.jsx +++ b/dashboard/src/components/MemoryPanel.jsx @@ -201,8 +201,6 @@ function MemoryTimelineItem({ entry }) { const [showModal, setShowModal] = useState(false); const timestamp = toDate(memoryTimestamp(entry)); const timeLabel = relativeTime(timestamp) || 'unknown'; - const rawPreview = String(entry.content || '').replace(/\s+/g, ' ').trim(); - const preview = rawPreview.length > 180 ? `${rawPreview.slice(0, 180).trimEnd()}…` : rawPreview; return ( <> @@ -214,7 +212,6 @@ function MemoryTimelineItem({ entry }) { added{' '} {entry.title || 'Untitled'} - {preview && -- {preview}} {entry.tags.length > 0 && ( {entry.tags.map((tag) => ( diff --git a/dashboard/src/components/PlanDetail.jsx b/dashboard/src/components/PlanDetail.jsx index 31a169f..afcf819 100644 --- a/dashboard/src/components/PlanDetail.jsx +++ b/dashboard/src/components/PlanDetail.jsx @@ -16,6 +16,14 @@ export default function PlanDetail({ planId, teamId, onClose }) { const [fullscreen, setFullscreen] = useState(false); useEffect(() => { + setPlan(null); + setContent(null); + setContentError(null); + setContentLoading(true); + setLoading(true); + setError(null); + setFullscreen(false); + const unsub = onSnapshot( doc(db, 'teams', teamId, 'plans', planId), (snap) => { @@ -52,22 +60,24 @@ export default function PlanDetail({ planId, teamId, onClose }) { return () => { cancelled = true; }; }, [teamId, planId]); - if (loading) { + const isSwitchingPlans = Boolean(plan && plan.id !== planId); + + if (error) { return (
e.stopPropagation()}> -
Loading plan...
+
{error}
+
); } - if (error) { + if (loading || isSwitchingPlans) { return (
e.stopPropagation()}> -
{error}
- +
Loading plan...
); diff --git a/dashboard/src/components/PlanDetail.test.jsx b/dashboard/src/components/PlanDetail.test.jsx index 5b615c4..4f3509a 100644 --- a/dashboard/src/components/PlanDetail.test.jsx +++ b/dashboard/src/components/PlanDetail.test.jsx @@ -57,4 +57,74 @@ describe('PlanDetail', () => { expect(screen.getByText('Body copy.')).toBeInTheDocument(); expect(getDocMock).toHaveBeenCalledTimes(1); }); + + it('does not keep showing the previous plan while switching to a new plan', async () => { + getDocMock.mockImplementation((ref) => { + if (ref.path.includes('/plan-1/content/current')) { + return Promise.resolve({ + exists: () => true, + data: () => ({ markdown: '# Plan One\n\nOld body.', revision: 1 }), + }); + } + + if (ref.path.includes('/plan-2/content/current')) { + return Promise.resolve({ + exists: () => true, + data: () => ({ markdown: '# Plan Two\n\nFresh body.', revision: 1 }), + }); + } + + return Promise.resolve({ + exists: () => false, + data: () => ({}), + }); + }); + + const { rerender } = render( {}} />); + + snapshotCallbacks[0]({ + exists: () => true, + id: 'plan-1', + data: () => ({ + author: 'Nathan', + status: 'in-progress', + summary: 'Plan one summary', + alignment: 'Ship sync', + outOfScope: 'History', + touches: ['src/cli.js'], + createdAt: new Date(), + updatedAt: new Date(), + updates: [], + }), + }); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Plan One' })).toBeInTheDocument(); + }); + + rerender( {}} />); + + expect(screen.getByText('Loading plan...')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Plan One' })).not.toBeInTheDocument(); + + snapshotCallbacks[1]({ + exists: () => true, + id: 'plan-2', + data: () => ({ + author: 'Nathan', + status: 'in-progress', + summary: 'Plan two summary', + alignment: 'Ship sync', + outOfScope: 'History', + touches: ['src/firestore.js'], + createdAt: new Date(), + updatedAt: new Date(), + updates: [], + }), + }); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Plan Two' })).toBeInTheDocument(); + }); + }); }); diff --git a/dashboard/src/components/UpdateFeed.jsx b/dashboard/src/components/UpdateFeed.jsx index e35cb7b..1f34981 100644 --- a/dashboard/src/components/UpdateFeed.jsx +++ b/dashboard/src/components/UpdateFeed.jsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { collection, onSnapshot } from 'firebase/firestore'; import { db } from '../firebase.js'; import { relativeTime, toDate } from '../utils.js'; +import ActivitySummary from './ActivitySummary.jsx'; export default function UpdateFeed({ teamId, onSelectPlan = null }) { const [plans, setPlans] = useState([]); @@ -68,6 +69,16 @@ export default function UpdateFeed({ teamId, onSelectPlan = null }) { const display = events.slice(0, visibleCount); const hasMore = events.length > display.length; + const stats = useMemo(() => { + const now = Date.now(); + const DAY = 86400000; + const createdToday = plans.filter(p => toDate(p.createdAt) > now - DAY).length; + const active = plans.filter(p => ['proposed', 'draft', 'in-progress', 'review'].includes(p.status)).length; + const merged = plans.filter(p => p.status === 'merged').length; + const contributors = new Set(plans.map(p => p.author).filter(Boolean)).size; + return { createdToday, active, merged, contributors }; + }, [plans]); + if (loading) { return (
@@ -98,6 +109,16 @@ export default function UpdateFeed({ teamId, onSelectPlan = null }) { return (

## activity

+ +
+ {stats.active} active + {stats.merged} merged + {stats.createdToday} new today + {stats.contributors} contributor{stats.contributors !== 1 ? 's' : ''} +
+ + +
{display.map((ev, i) => ( ))} diff --git a/dashboard/src/components/UpdateFeed.test.jsx b/dashboard/src/components/UpdateFeed.test.jsx index 1d90d58..d8fb860 100644 --- a/dashboard/src/components/UpdateFeed.test.jsx +++ b/dashboard/src/components/UpdateFeed.test.jsx @@ -2,12 +2,13 @@ import '@testing-library/jest-dom/vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { vi, describe, it, beforeEach, expect } from 'vitest'; -const snapshotCallbacks = []; +const snapshotCallbacks = new Map(); vi.mock('firebase/firestore', () => ({ - collection: (...parts) => ({ path: parts.join('/') }), + collection: (...parts) => ({ path: parts.filter((part) => typeof part === 'string').join('/') }), + doc: (...parts) => ({ path: parts.filter((part) => typeof part === 'string').join('/') }), onSnapshot: (_ref, onNext) => { - snapshotCallbacks.push(onNext); + snapshotCallbacks.set(_ref.path, onNext); return () => {}; }, })); @@ -32,30 +33,54 @@ function makePlan(id, minuteOffset) { describe('UpdateFeed', () => { beforeEach(() => { - snapshotCallbacks.length = 0; + snapshotCallbacks.clear(); }); it('shows the 10 most recent entries first and loads more on demand', async () => { render(); - snapshotCallbacks[0]({ + await waitFor(() => expect(snapshotCallbacks.has('teams/team-1/plans')).toBe(true)); + + snapshotCallbacks.get('teams/team-1/plans')({ docs: [ - makePlan('1', 1), - makePlan('2', 2), - makePlan('3', 3), - makePlan('4', 4), - makePlan('5', 5), - makePlan('6', 6), - makePlan('7', 7), - makePlan('8', 8), - makePlan('9', 9), - makePlan('10', 10), - makePlan('11', 11), + { ...makePlan('1', 1), data: () => ({ ...makePlan('1', 1).data(), status: 'review' }) }, + { ...makePlan('2', 2), data: () => ({ ...makePlan('2', 2).data(), status: 'in-progress' }) }, + { ...makePlan('3', 3), data: () => ({ ...makePlan('3', 3).data(), status: 'draft' }) }, + { ...makePlan('4', 4), data: () => ({ ...makePlan('4', 4).data(), status: 'merged' }) }, + { ...makePlan('5', 5), data: () => ({ ...makePlan('5', 5).data(), status: 'review' }) }, + { ...makePlan('6', 6), data: () => ({ ...makePlan('6', 6).data(), status: 'in-progress' }) }, + { ...makePlan('7', 7), data: () => ({ ...makePlan('7', 7).data(), status: 'draft' }) }, + { ...makePlan('8', 8), data: () => ({ ...makePlan('8', 8).data(), status: 'review' }) }, + { ...makePlan('9', 9), data: () => ({ ...makePlan('9', 9).data(), status: 'merged' }) }, + { ...makePlan('10', 10), data: () => ({ ...makePlan('10', 10).data(), status: 'review' }) }, + { ...makePlan('11', 11), data: () => ({ ...makePlan('11', 11).data(), status: 'in-progress' }) }, ], }); + await waitFor(() => expect(snapshotCallbacks.has('teams/team-1/insights/activity-summary')).toBe(true)); + + snapshotCallbacks.get('teams/team-1/insights/activity-summary')({ + exists: () => true, + data: () => ({ + status: 'ready', + model: 'google/gemini-3.1-flash-lite-preview', + generatedAt: new Date(), + confidence: 0.88, + headline: 'Activity is active and broadly aligned.', + summaryBullets: ['Four contributors are active today.', 'Review traffic is the main source of churn.'], + riskFlags: ['One plan has moved to merged while others are still in review.'], + nextActions: ['Close the review queue before adding new plans.'], + sourceWindow: { recentActivityCount: 6 }, + }), + }); + await waitFor(() => expect(screen.getByText('plan-1')).toBeInTheDocument()); + expect(screen.getByText('## ai summary')).toBeInTheDocument(); + expect(screen.getByText('Activity is active and broadly aligned.')).toBeInTheDocument(); + expect(screen.getByText('Four contributors are active today.')).toBeInTheDocument(); + expect(screen.getByText(/google\/gemini-3.1-flash-lite-preview/)).toBeInTheDocument(); + expect(screen.getByText('plan-1')).toBeInTheDocument(); expect(screen.getByText('plan-10')).toBeInTheDocument(); expect(screen.queryByText('plan-11')).not.toBeInTheDocument(); diff --git a/dashboard/src/lib/planTags.js b/dashboard/src/lib/planTags.js index fda404f..801bec6 100644 --- a/dashboard/src/lib/planTags.js +++ b/dashboard/src/lib/planTags.js @@ -17,6 +17,16 @@ function getUpdatedAtMs(plan) { return 0; } +function getCreatedAtMs(plan) { + const createdAt = plan?.createdAt; + if (!createdAt) return 0; + if (createdAt instanceof Date) return createdAt.getTime(); + if (typeof createdAt.toDate === 'function') return createdAt.toDate().getTime(); + if (typeof createdAt.seconds === 'number') return createdAt.seconds * 1000; + if (typeof createdAt === 'number') return createdAt; + return 0; +} + export function getPlanGoalTags(plan) { const alignment = normalizeText(plan?.alignment); const summary = normalizeText(plan?.summary); @@ -70,6 +80,8 @@ export function findGoalLinkedPlan(plans, goalType, goalContent) { scored.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; + const createdDelta = getCreatedAtMs(b.plan) - getCreatedAtMs(a.plan); + if (createdDelta !== 0) return createdDelta; return getUpdatedAtMs(b.plan) - getUpdatedAtMs(a.plan); }); diff --git a/firestore.rules b/firestore.rules index 2107688..ad9aa3b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -54,6 +54,11 @@ service cloud.firestore { } } + match /insights/{insightId} { + allow read: if teamMember(teamId); + allow write: if false; + } + match /memberships/{seatId} { allow read: if teamMember(teamId); allow write: if false; diff --git a/functions/index.js b/functions/index.js index 6045228..b412878 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,10 +1,32 @@ const functions = require("firebase-functions"); const admin = require("firebase-admin"); const { FieldValue } = require("firebase-admin/firestore"); +const fs = require("node:fs"); +const path = require("node:path"); const express = require("express"); const cors = require("cors"); const { v4: uuidv4 } = require("uuid"); +const dotenv = require("dotenv"); const { generateJoinCode, sha256 } = require("./join-codes"); +const { + FEATURE_KEY: ACTIVITY_SUMMARY_FEATURE_KEY, + buildActivitySummaryDocument, + buildActivitySummaryErrorDocument, + buildActivitySummaryFingerprint, + buildActivitySummarySource, + generateActivitySummary, +} = require("./insights/activity-summary.cjs"); + +for (const candidate of [ + path.join(__dirname, ".env"), + path.join(__dirname, ".env.local"), + path.join(__dirname, "..", ".env"), + path.join(__dirname, "..", ".env.local"), +]) { + if (fs.existsSync(candidate)) { + dotenv.config({ path: candidate, override: false }); + } +} admin.initializeApp(); const db = admin.firestore(); @@ -61,6 +83,16 @@ async function requireTeamAdmin(req, { adminClient = admin, dbClient = db } = {} }; } +async function requireScopedTeamAdmin(req, requestedTeamId, deps = {}) { + const auth = await requireTeamAdmin(req, deps); + if (requestedTeamId && requestedTeamId !== auth.teamId) { + const err = new Error("Admins can only refresh insights for their own team"); + err.status = 403; + throw err; + } + return auth; +} + function createJoinCodeDoc(dbClient, batch, teamId, { seatId, seatName }) { const joinCode = generateJoinCode(); const joinCodeRef = dbClient.doc(`teams/${teamId}/joinCodes/${uuidv4()}`); @@ -168,6 +200,82 @@ async function joinTeamWithCode({ dbClient = db, adminClient = admin, joinCode, }; } +async function loadActivitySummarySource(teamId) { + const [teamSnap, twoWeekSnap, threeDaySnap, plansSnap] = await Promise.all([ + db.doc(`teams/${teamId}`).get(), + db.doc(`teams/${teamId}/meta/2week`).get(), + db.doc(`teams/${teamId}/meta/3day`).get(), + db.collection(`teams/${teamId}/plans`).get(), + ]); + + const teamName = teamSnap.exists ? teamSnap.data().name || null : null; + return buildActivitySummarySource({ + teamId, + teamName, + twoWeekGoal: twoWeekSnap.exists ? twoWeekSnap.data().content || null : null, + threeDayGoal: threeDaySnap.exists ? threeDaySnap.data().content || null : null, + plans: plansSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() })), + }); +} + +async function refreshActivitySummary(teamId) { + const summaryRef = db.doc(`teams/${teamId}/insights/${ACTIVITY_SUMMARY_FEATURE_KEY}`); + const previousSnap = await summaryRef.get(); + const previous = previousSnap.exists ? previousSnap.data() : null; + + const source = await loadActivitySummarySource(teamId); + const sourceFingerprint = buildActivitySummaryFingerprint(source); + + if (previous?.sourceFingerprint === sourceFingerprint && previous?.status === 'ready') { + return { refreshed: false, skipped: true, reason: 'source unchanged', sourceFingerprint }; + } + + let generated; + try { + generated = await generateActivitySummary({ source }); + } catch (error) { + console.error(`[activity-summary] generation failed team=${teamId}:`, error); + const errorDoc = buildActivitySummaryErrorDocument({ + teamId, + source, + error, + sourceFingerprint, + previousDocument: previous, + }); + await summaryRef.set(errorDoc, { merge: true }); + throw error; + } + + const latestSource = await loadActivitySummarySource(teamId); + const latestFingerprint = buildActivitySummaryFingerprint(latestSource); + if (latestFingerprint !== sourceFingerprint) { + return { refreshed: false, skipped: true, reason: 'source changed during generation', sourceFingerprint }; + } + + const currentSnap = await summaryRef.get(); + const currentGeneratedAt = currentSnap.exists ? Date.parse(currentSnap.data().generatedAt || '') : NaN; + const previousGeneratedAt = previous ? Date.parse(previous.generatedAt || '') : NaN; + if ( + Number.isFinite(currentGeneratedAt) + && Number.isFinite(previousGeneratedAt) + && currentGeneratedAt > previousGeneratedAt + && (currentSnap.data().sourceFingerprint || null) !== sourceFingerprint + ) { + return { refreshed: false, skipped: true, reason: 'a newer summary already exists', sourceFingerprint }; + } + + const docData = buildActivitySummaryDocument({ + teamId, + source: latestSource, + model: generated.model, + output: generated.output, + sourceFingerprint, + previousDocument: previous, + }); + await summaryRef.set(docData, { merge: true }); + return { refreshed: true, skipped: false, sourceFingerprint, model: generated.model }; +} + // --------------------------------------------------------------------------- // POST /teams — create a new team + first admin seat // --------------------------------------------------------------------------- @@ -330,12 +438,53 @@ router.post("/agent/login", async (req, res) => { } }); +async function handleActivitySummaryRefresh(req, res) { + try { + const requestedTeamId = req.params.teamId || req.body.teamId || null; + const { teamId } = await requireScopedTeamAdmin(req, requestedTeamId); + + const result = await refreshActivitySummary(teamId); + return res.status(200).json({ teamId, featureKey: ACTIVITY_SUMMARY_FEATURE_KEY, ...result }); + } catch (err) { + console.error("refreshActivitySummary error:", err); + return sendApiError(res, err, { fallbackMessage: "Could not refresh activity summary" }); + } +} + +router.post("/teams/:teamId/insights/activity-summary/refresh", handleActivitySummaryRefresh); +router.post("/insights/activity-summary/refresh", handleActivitySummaryRefresh); + // Support both direct function URLs (`/agent/login`) and Hosting rewrites that // preserve the `/api` prefix (`/api/agent/login`). app.use(router); app.use("/api", router); +exports.onPlanWriteActivitySummary = functions.firestore + .document("teams/{teamId}/plans/{planId}") + .onWrite(async (_change, context) => { + const { teamId } = context.params; + try { + await refreshActivitySummary(teamId); + } catch (err) { + console.error(`Activity summary refresh failed for team ${teamId}:`, err); + } + }); + +exports.onTeamGoalWriteActivitySummary = functions.firestore + .document("teams/{teamId}/meta/{docId}") + .onWrite(async (_change, context) => { + const { teamId, docId } = context.params; + if (!['2week', '3day'].includes(docId)) return; + try { + await refreshActivitySummary(teamId); + } catch (err) { + console.error(`Activity summary refresh failed for team ${teamId} meta ${docId}:`, err); + } + }); + exports.api = functions.https.onRequest(app); module.exports.requireTeamAdmin = requireTeamAdmin; +module.exports.requireScopedTeamAdmin = requireScopedTeamAdmin; module.exports.issueJoinCodeForTeam = issueJoinCodeForTeam; module.exports.joinTeamWithCode = joinTeamWithCode; +module.exports.refreshActivitySummary = refreshActivitySummary; diff --git a/functions/insights/activity-summary.cjs b/functions/insights/activity-summary.cjs new file mode 100644 index 0000000..2ab60ba --- /dev/null +++ b/functions/insights/activity-summary.cjs @@ -0,0 +1,321 @@ +const crypto = require('node:crypto'); +const { z } = require('zod'); + +const FEATURE_KEY = 'activity-summary'; +const DEFAULT_MODEL = 'gemini-3.1-flash-lite-preview'; +const MODEL_ALLOWLIST = new Set([ + 'gemini-3.1-flash-lite-preview', + 'gemini-3.1-flash-lite', + 'gemini-2.5-flash-lite-preview', + 'gemini-2.5-flash-lite', +]); + +const ACTIVE_STATUSES = new Set(['proposed', 'draft', 'in-progress', 'review']); +const SUMMARY_SCHEMA = z.object({ + headline: z.string().min(1).max(160), + summaryBullets: z.array(z.string().min(1).max(220)).min(1).max(4), + riskFlags: z.array(z.string().min(1).max(220)).max(4), + nextActions: z.array(z.string().min(1).max(220)).max(4), + confidence: z.number().min(0).max(1), +}); + +function toMillis(timestamp) { + if (!timestamp) return 0; + if (typeof timestamp.toMillis === 'function') return timestamp.toMillis(); + if (typeof timestamp.toDate === 'function') return timestamp.toDate().getTime(); + if (timestamp instanceof Date) return timestamp.getTime(); + if (typeof timestamp.seconds === 'number') return timestamp.seconds * 1000; + if (typeof timestamp === 'string') { + const parsed = new Date(timestamp); + return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime(); + } + return 0; +} + +function toIso(timestamp) { + if (!timestamp) return null; + if (typeof timestamp.toDate === 'function') return timestamp.toDate().toISOString(); + if (typeof timestamp.toMillis === 'function') return new Date(timestamp.toMillis()).toISOString(); + if (timestamp instanceof Date) return timestamp.toISOString(); + if (typeof timestamp.seconds === 'number') return new Date(timestamp.seconds * 1000).toISOString(); + if (typeof timestamp === 'string') return timestamp; + return String(timestamp); +} + +function getAiApiKey(env = process.env) { + return env.GEMINI_API_KEY + || env.GOOGLE_GENERATIVE_AI_API_KEY + || env.GOOGLE_API_KEY + || null; +} + +function resolveActivitySummaryModel(env = process.env) { + const provider = String(env.AI_INSIGHTS_PROVIDER || 'google').trim().toLowerCase(); + const model = String(env.AI_INSIGHTS_ACTIVITY_SUMMARY_MODEL || DEFAULT_MODEL).trim(); + + if (provider !== 'google') { + throw new Error(`Unsupported AI provider: ${provider}`); + } + + if (!MODEL_ALLOWLIST.has(model)) { + throw new Error(`Unsupported activity summary model: ${model}`); + } + + return { provider, model }; +} + +function normalizePlan(plan) { + if (!plan) return null; + return { + id: plan.id || null, + slug: String(plan.slug || plan.id || '').trim() || 'untitled', + status: String(plan.status || 'unknown').trim(), + author: String(plan.author || 'unknown').trim(), + summary: String(plan.summary || '').trim(), + createdAt: plan.createdAt || null, + updatedAt: plan.updatedAt || null, + updates: Array.isArray(plan.updates) ? plan.updates : [], + touches: Array.isArray(plan.touches) ? plan.touches : [], + prUrl: plan.prUrl || null, + }; +} + +function summarizeRecentUpdates(plan) { + return plan.updates + .map((update) => ({ + timestamp: update.timestamp || null, + author: String(update.author || plan.author || 'unknown').trim(), + action: 'updated', + planId: plan.id, + slug: plan.slug, + note: String(update.note || '').trim(), + })) + .filter((event) => event.timestamp); +} + +function buildActivitySummarySource({ + teamId, + teamName = null, + twoWeekGoal = null, + threeDayGoal = null, + plans = [], + now = new Date(), +}) { + const normalizedPlans = plans.map(normalizePlan).filter(Boolean); + const nowMs = now.getTime(); + const dayMs = 24 * 60 * 60 * 1000; + + const createdToday = normalizedPlans.filter((plan) => toMillis(plan.createdAt) >= nowMs - dayMs).length; + const activePlans = normalizedPlans.filter((plan) => ACTIVE_STATUSES.has(plan.status)).length; + const mergedPlans = normalizedPlans.filter((plan) => plan.status === 'merged').length; + const contributors = [...new Set(normalizedPlans.map((plan) => plan.author).filter(Boolean))].sort(); + + const recentPlans = [...normalizedPlans] + .sort((left, right) => toMillis(right.updatedAt) - toMillis(left.updatedAt)) + .slice(0, 8) + .map((plan) => ({ + id: plan.id, + slug: plan.slug, + status: plan.status, + author: plan.author, + summary: plan.summary, + updatedAt: toIso(plan.updatedAt), + createdAt: toIso(plan.createdAt), + touches: plan.touches.slice(0, 6), + prUrl: plan.prUrl, + })); + + const recentActivity = [...normalizedPlans] + .flatMap((plan) => { + const events = []; + if (plan.createdAt) { + events.push({ + timestamp: plan.createdAt, + author: plan.author, + action: 'created', + planId: plan.id, + slug: plan.slug, + note: plan.summary, + }); + } + events.push(...summarizeRecentUpdates(plan)); + return events; + }) + .filter((event) => event.timestamp) + .sort((left, right) => toMillis(right.timestamp) - toMillis(left.timestamp)) + .slice(0, 12) + .map((event) => ({ + timestamp: toIso(event.timestamp), + author: event.author, + action: event.action, + planId: event.planId, + slug: event.slug, + note: String(event.note || '').slice(0, 180), + })); + + return { + scope: { + kind: 'team', + id: teamId, + name: teamName, + }, + goals: { + twoWeek: String(twoWeekGoal || '').trim(), + threeDay: String(threeDayGoal || '').trim(), + }, + stats: { + totalPlans: normalizedPlans.length, + activePlans, + mergedPlans, + createdToday, + contributors: contributors.length, + contributorNames: contributors, + }, + recentPlans, + recentActivity, + sourceWindow: { + planCount: normalizedPlans.length, + recentPlanCount: recentPlans.length, + recentActivityCount: recentActivity.length, + }, + }; +} + +function buildActivitySummaryPrompt(source) { + return JSON.stringify({ + feature: FEATURE_KEY, + scope: source.scope, + goals: source.goals, + stats: source.stats, + recentPlans: source.recentPlans, + recentActivity: source.recentActivity, + sourceWindow: source.sourceWindow, + instructions: [ + 'Use only the supplied data.', + 'Do not invent plans, events, goals, or risks.', + 'Write for a dashboard above the activity feed.', + 'Keep it concise, direct, and operational.', + 'Mention coordination risk when the recent activity suggests drift or overload.', + 'Prefer exact plan slugs, statuses, and updates from the input.', + ], + }, null, 2); +} + +function buildActivitySummaryFingerprint(source) { + return crypto.createHash('sha256').update(JSON.stringify(source)).digest('hex'); +} + +async function generateActivitySummary({ source, env = process.env }) { + const apiKey = getAiApiKey(env); + if (!apiKey) { + throw new Error('GEMINI_API_KEY is required to generate the activity summary.'); + } + + const { provider, model } = resolveActivitySummaryModel(env); + if (provider !== 'google') { + throw new Error(`Unsupported provider: ${provider}`); + } + + const [{ generateObject }, { createGoogleGenerativeAI }] = await Promise.all([ + import('ai'), + import('@ai-sdk/google'), + ]); + + const google = createGoogleGenerativeAI({ apiKey }); + const modelHandle = google(model); + const prompt = buildActivitySummaryPrompt(source); + + const result = await generateObject({ + model: modelHandle, + schema: SUMMARY_SCHEMA, + system: [ + 'You are writing a concise AI summary for a team coordination dashboard.', + 'The output will be shown above the activity feed.', + 'Use only the data supplied in the prompt.', + 'Return plain operational language, not marketing copy.', + 'If the data is sparse, say so in the headline and keep bullets conservative.', + ].join(' '), + prompt, + temperature: 0.2, + }); + + return { + model: `${provider}/${model}`, + output: result.object, + }; +} + +function buildActivitySummaryDocument({ + teamId, + source, + model, + output, + sourceFingerprint, + now = new Date(), + previousDocument = null, +}) { + const payload = { + featureKey: FEATURE_KEY, + scope: source.scope, + status: 'ready', + model, + sourceFingerprint, + sourceWindow: source.sourceWindow, + stats: source.stats, + goals: source.goals, + generatedAt: now.toISOString(), + updatedAt: now.toISOString(), + headline: output.headline, + summaryBullets: output.summaryBullets, + riskFlags: output.riskFlags, + nextActions: output.nextActions, + confidence: output.confidence, + }; + + if (previousDocument && previousDocument.error) { + payload.error = null; + } + + return payload; +} + +function buildActivitySummaryErrorDocument({ + teamId, + source, + model = null, + error, + sourceFingerprint, + now = new Date(), + previousDocument = null, +}) { + return { + ...(previousDocument || {}), + featureKey: FEATURE_KEY, + scope: source.scope, + status: 'error', + model, + sourceFingerprint, + sourceWindow: source.sourceWindow, + stats: source.stats, + goals: source.goals, + generatedAt: previousDocument?.generatedAt || null, + updatedAt: now.toISOString(), + error: error instanceof Error ? error.message : String(error || 'Unknown error'), + }; +} + +module.exports = { + DEFAULT_MODEL, + FEATURE_KEY, + MODEL_ALLOWLIST, + SUMMARY_SCHEMA, + buildActivitySummaryDocument, + buildActivitySummaryErrorDocument, + buildActivitySummaryFingerprint, + buildActivitySummaryPrompt, + buildActivitySummarySource, + generateActivitySummary, + getAiApiKey, + normalizePlan, + resolveActivitySummaryModel, +}; diff --git a/functions/package-lock.json b/functions/package-lock.json index 915394c..616c504 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,16 +6,82 @@ "": { "name": "gsync-functions", "dependencies": { + "@ai-sdk/google": "^3.0.61", + "ai": "^6.0.158", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^4.21.0", "firebase-admin": "^12.0.0", "firebase-functions": "^5.0.0", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "zod": "^4.3.6" }, "engines": { "node": "20" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.95", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.95.tgz", + "integrity": "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.61", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.61.tgz", + "integrity": "sha512-jEKU1Mjcy5CoicejdJQIzM0ntYwyXR8vtYgAZYriKaOuLAiAhiiU538++fGU3CC9HJH/mL1OfsCwMM3gFiCNsw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", + "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@fastify/busboy": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", @@ -332,6 +398,12 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -489,6 +561,15 @@ "license": "MIT", "optional": true }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -525,6 +606,33 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.158", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.158.tgz", + "integrity": "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.95", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -822,6 +930,18 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -971,6 +1091,15 @@ "node": ">=6" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1634,6 +1763,12 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2715,6 +2850,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/functions/package.json b/functions/package.json index 2b1249a..39e5d96 100644 --- a/functions/package.json +++ b/functions/package.json @@ -6,10 +6,14 @@ }, "main": "index.js", "dependencies": { + "@ai-sdk/google": "^3.0.61", + "ai": "^6.0.158", + "dotenv": "^17.2.1", "firebase-admin": "^12.0.0", "firebase-functions": "^5.0.0", "express": "^4.21.0", "cors": "^2.8.5", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "zod": "^4.3.6" } } diff --git a/test/activity-summary.test.js b/test/activity-summary.test.js new file mode 100644 index 0000000..7389e6e --- /dev/null +++ b/test/activity-summary.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + buildActivitySummaryFingerprint, + buildActivitySummaryPrompt, + buildActivitySummarySource, + resolveActivitySummaryModel, +} = require('../functions/insights/activity-summary.cjs'); + +test('buildActivitySummarySource keeps payload compact and counts activity correctly', () => { + const source = buildActivitySummarySource({ + teamId: 'team-1', + teamName: 'Alpha', + twoWeekGoal: 'Ship the summary block', + threeDayGoal: 'Wire the live refresh', + now: new Date('2026-04-11T12:00:00.000Z'), + plans: [ + { + id: 'p1', + slug: 'plan-one', + author: 'alex', + status: 'review', + summary: 'First plan', + createdAt: new Date('2026-04-11T09:00:00.000Z'), + updatedAt: new Date('2026-04-11T11:30:00.000Z'), + updates: [{ timestamp: new Date('2026-04-11T11:45:00.000Z'), author: 'alex', note: 'Moved to review' }], + }, + { + id: 'p2', + slug: 'plan-two', + author: 'sam', + status: 'merged', + summary: 'Second plan', + createdAt: new Date('2026-04-10T09:00:00.000Z'), + updatedAt: new Date('2026-04-11T10:30:00.000Z'), + updates: [], + }, + ], + }); + + assert.equal(source.scope.kind, 'team'); + assert.equal(source.stats.totalPlans, 2); + assert.equal(source.stats.activePlans, 1); + assert.equal(source.stats.mergedPlans, 1); + assert.equal(source.stats.createdToday, 1); + assert.equal(source.stats.contributors, 2); + assert.equal(source.recentPlans[0].slug, 'plan-one'); + assert.equal(source.recentActivity[0].action, 'updated'); +}); + +test('buildActivitySummaryPrompt is explicit about the model payload', () => { + const source = buildActivitySummarySource({ + teamId: 'team-1', + plans: [], + now: new Date('2026-04-11T12:00:00.000Z'), + }); + + const prompt = buildActivitySummaryPrompt(source); + assert.match(prompt, /activity-summary/); + assert.match(prompt, /Use only the supplied data/); + assert.match(prompt, /recentPlans/); +}); + +test('resolveActivitySummaryModel defaults to the newest flash lite model', () => { + const model = resolveActivitySummaryModel({ + AI_INSIGHTS_PROVIDER: 'google', + }); + + assert.equal(model.provider, 'google'); + assert.equal(model.model, 'gemini-3.1-flash-lite-preview'); +}); + +test('buildActivitySummaryFingerprint is stable for the same source', () => { + const source = buildActivitySummarySource({ + teamId: 'team-1', + plans: [], + now: new Date('2026-04-11T12:00:00.000Z'), + }); + + const a = buildActivitySummaryFingerprint(source); + const b = buildActivitySummaryFingerprint(source); + assert.equal(a, b); +}); diff --git a/test/join-flow-api.test.cjs b/test/join-flow-api.test.cjs index eb49e2f..8de8fa1 100644 --- a/test/join-flow-api.test.cjs +++ b/test/join-flow-api.test.cjs @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const { sha256 } = require('../functions/join-codes'); const { requireTeamAdmin, + requireScopedTeamAdmin, issueJoinCodeForTeam, joinTeamWithCode, } = require('../functions/index.js'); @@ -156,6 +157,18 @@ test('requireTeamAdmin rejects non-admin seats', async () => { ); }); +test('requireScopedTeamAdmin rejects refreshing a different team', async () => { + const db = createMemoryDb({ + 'teams/team-1/memberships/seat-admin': { role: 'admin', seatName: 'Admin Seat' }, + }); + const req = { get: () => 'Bearer token-admin' }; + + await assert.rejects( + () => requireScopedTeamAdmin(req, 'team-2', { adminClient: createAdminClient(), dbClient: db }), + /Admins can only refresh insights for their own team/, + ); +}); + test('issueJoinCodeForTeam always stores member invites', async () => { const db = createMemoryDb(); const result = await issueJoinCodeForTeam({