@@ -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({