diff --git a/app/api/cron/maintainer-outreach-report/route.js b/app/api/cron/maintainer-outreach-report/route.js new file mode 100644 index 0000000..c0c4070 --- /dev/null +++ b/app/api/cron/maintainer-outreach-report/route.js @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { sendMaintainerOutreachReport } from '../../../../lib/maintainer-outreach-report.js'; + +export const maxDuration = 300; + +export async function GET(request) { + const cronSecret = process.env.CRON_SECRET?.trim(); + if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + try { + const summary = await sendMaintainerOutreachReport(); + console.info(JSON.stringify({ event: 'devglobe_maintainer_outreach_report', outcome: 'completed', ...summary })); + return NextResponse.json({ ok: true, ...summary }); + } catch { + console.error(JSON.stringify({ event: 'devglobe_maintainer_outreach_report', outcome: 'failed' })); + return NextResponse.json({ error: 'Maintainer outreach report failed' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/cron/maintainer-outreach/route.js b/app/api/cron/maintainer-outreach/route.js new file mode 100644 index 0000000..e8f63a2 --- /dev/null +++ b/app/api/cron/maintainer-outreach/route.js @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { runMaintainerOutreachSchedule } from '../../../../lib/maintainer-outreach-scheduler.js'; + +export const maxDuration = 300; + +export async function GET(request) { + const cronSecret = process.env.CRON_SECRET?.trim(); + if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const summary = await runMaintainerOutreachSchedule(); + console.info(JSON.stringify({ event: 'devglobe_maintainer_outreach_queue', outcome: 'completed', ...summary })); + return NextResponse.json({ ok: true, ...summary }); + } catch { + console.error(JSON.stringify({ event: 'devglobe_maintainer_outreach_queue', outcome: 'failed' })); + return NextResponse.json({ error: 'Maintainer outreach queue failed' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/docs/azure-backend.md b/docs/azure-backend.md index 3ac777d..b22d907 100644 --- a/docs/azure-backend.md +++ b/docs/azure-backend.md @@ -33,6 +33,8 @@ The Container App and Function App require their existing Cosmos, GitHub, and Az The `repository-agent-ingest` timer uses the Function App's `GITHUB_TOKEN` to scan a bounded batch of stale developer profiles. It examines filenames from up to eight recent public, non-fork, non-archived owner repositories and stores the resulting tool IDs and evidence on the existing developer document. It never reads or stores repository file contents. Each profile is refreshed after seven days, and GitHub rate-limit responses stop the current batch. +The `maintainer-outreach` timer creates up to ten deduplicated review-only drafts each day through the protected Container App endpoint. It never sends outreach. Operators explicitly approve and record manual sends with `npm run outreach:review`. The `maintainer-outreach-report` timer emails aggregate weekly outcomes to `GROWTH_REPORT_EMAIL`; its Function App settings are `MAINTAINER_OUTREACH_URL`, `MAINTAINER_OUTREACH_REPORT_URL`, and the shared `CRON_SECRET`. + Repository evidence is observational. It does not imply that a developer personally uses a tool or consents to agent contact; only a public, self-declared AI profile controls contact availability. ## Frontend configuration diff --git a/docs/prd/maintainer-outreach-queue.md b/docs/prd/maintainer-outreach-queue.md new file mode 100644 index 0000000..f9ff4c6 --- /dev/null +++ b/docs/prd/maintainer-outreach-queue.md @@ -0,0 +1,52 @@ +# Maintainer Outreach Review Queue + +## Issue + +GitHub issue #384: Growth: automate maintainer outreach review queue. + +## Problem + +DevGlobe can generate activation copy, but candidate selection, deduplication, review state, follow-up timing, and outcome reporting are manual. Repeating that work daily is costly and makes growth experiments inconsistent. + +## Goals + +- Select up to ten high-signal unclaimed public profiles each day. +- Create personalized campaign-attributed drafts without duplicating queued people. +- Require explicit operator approval and manual delivery. +- Prepare at most one follow-up, four days after a recorded send. +- Report selected, approved, contacted, profile-viewed, and claimed counts. + +## Non-goals + +- Sending email, direct messages, GitHub comments, or social posts. +- Discovering or storing private contact details. +- More than two outreach attempts per person. +- Bypassing platform anti-spam or moderation controls. + +## Workflow + +1. The daily timer calls the protected queue endpoint at 13:30 UTC. +2. The scheduler selects public, unclaimed profiles and creates pending drafts. +3. An operator lists drafts and records approval, rejection, and manual sends through the CLI. +4. A sent first attempt becomes eligible for one follow-up after four days. +5. The report command joins bounded campaign engagement into aggregate funnel counts. +6. A weekly timer emails the aggregate report to `GROWTH_REPORT_EMAIL`. + +## Commands + +```powershell +npm run setup-maintainer-outreach-container +npm run outreach:queue +npm run outreach:review -- list pending +npm run outreach:review -- approve octocat operator +npm run outreach:review -- sent octocat operator +npm run outreach:review -- report +``` + +## Acceptance Criteria + +- Repeated daily runs do not duplicate pending or rejected profiles. +- Every generated link uses the bounded `manual_outreach` and `developer_activation` attribution. +- Sending requires an explicit out-of-band operator action; application code cannot deliver drafts. +- A profile receives no more than one follow-up draft. +- State transitions and aggregate reporting are covered by focused tests. \ No newline at end of file diff --git a/functions/maintainer-outreach-report/function.json b/functions/maintainer-outreach-report/function.json new file mode 100644 index 0000000..dcbb7b0 --- /dev/null +++ b/functions/maintainer-outreach-report/function.json @@ -0,0 +1,13 @@ +{ + "bindings": [ + { + "name": "timer", + "type": "timerTrigger", + "direction": "in", + "schedule": "0 30 13 * * 1", + "runOnStartup": false, + "useMonitor": true + } + ], + "scriptFile": "index.js" +} \ No newline at end of file diff --git a/functions/maintainer-outreach-report/index.js b/functions/maintainer-outreach-report/index.js new file mode 100644 index 0000000..3faef9d --- /dev/null +++ b/functions/maintainer-outreach-report/index.js @@ -0,0 +1,19 @@ +module.exports = async function maintainerOutreachReport(context) { + const endpoint = process.env.MAINTAINER_OUTREACH_REPORT_URL; + const secret = process.env.CRON_SECRET; + if (!endpoint || !secret) throw new Error('MAINTAINER_OUTREACH_REPORT_URL and CRON_SECRET are required'); + + const response = await fetch(endpoint, { + headers: { Authorization: `Bearer ${secret}` }, + }); + const result = await response.json(); + context.log('DevGlobe maintainer outreach report', { + status: response.status, + selected: result.selected, + contacted: result.contacted, + claimed: result.claimed, + reportSent: result.reportSent, + reason: result.reason, + }); + if (!response.ok) throw new Error(`Maintainer outreach report returned ${response.status}`); +}; \ No newline at end of file diff --git a/functions/maintainer-outreach/function.json b/functions/maintainer-outreach/function.json new file mode 100644 index 0000000..6be04ad --- /dev/null +++ b/functions/maintainer-outreach/function.json @@ -0,0 +1,13 @@ +{ + "bindings": [ + { + "name": "timer", + "type": "timerTrigger", + "direction": "in", + "schedule": "0 30 13 * * *", + "runOnStartup": false, + "useMonitor": true + } + ], + "scriptFile": "index.js" +} \ No newline at end of file diff --git a/functions/maintainer-outreach/index.js b/functions/maintainer-outreach/index.js new file mode 100644 index 0000000..6f6f9bf --- /dev/null +++ b/functions/maintainer-outreach/index.js @@ -0,0 +1,17 @@ +module.exports = async function maintainerOutreach(context) { + const endpoint = process.env.MAINTAINER_OUTREACH_URL; + const secret = process.env.CRON_SECRET; + if (!endpoint || !secret) throw new Error('MAINTAINER_OUTREACH_URL and CRON_SECRET are required'); + + const response = await fetch(endpoint, { + headers: { Authorization: `Bearer ${secret}` }, + }); + const result = await response.json(); + context.log('DevGlobe maintainer outreach queue', { + status: response.status, + selected: result.selected, + queued: result.queued, + delivery: result.delivery, + }); + if (!response.ok) throw new Error(`Maintainer outreach queue returned ${response.status}`); +}; \ No newline at end of file diff --git a/lib/maintainer-outreach-report.js b/lib/maintainer-outreach-report.js new file mode 100644 index 0000000..f09d53a --- /dev/null +++ b/lib/maintainer-outreach-report.js @@ -0,0 +1,41 @@ +import { sendLifecycleEmail } from './lifecycle-email.js'; +import { getMaintainerOutreachReport } from './maintainer-outreach-scheduler.js'; + +function percentage(numerator, denominator) { + return denominator ? Math.round((1000 * numerator) / denominator) / 10 : 0; +} + +export function buildMaintainerOutreachReportEmail(report) { + const visitRate = percentage(report.profileViewed, report.contacted); + const claimRate = percentage(report.claimed, report.contacted); + const lines = [ + `Selected: ${report.selected}`, + `Pending review: ${report.pending}`, + `Approved: ${report.approved}`, + `Contacted: ${report.contacted}`, + `Profile viewed: ${report.profileViewed} (${visitRate}%)`, + `Claimed: ${report.claimed} (${claimRate}%)`, + ]; + return { + subject: 'DevGlobe weekly maintainer outreach report', + text: `DevGlobe maintainer outreach\n\n${lines.join('\n')}\n\nDraft delivery remains manual.`, + html: `
Draft delivery remains manual.
`, + }; +} + +export async function sendMaintainerOutreachReport({ + now = new Date(), + recipient = process.env.GROWTH_REPORT_EMAIL?.trim(), + loadReport = getMaintainerOutreachReport, + sendEmail = sendLifecycleEmail, +} = {}) { + const report = await loadReport({ now }); + if (!recipient) return { ...report, reportSent: false, reason: 'missing_recipient' }; + const week = now.toISOString().slice(0, 10); + const delivery = await sendEmail({ + to: recipient, + message: buildMaintainerOutreachReportEmail(report), + idempotencyKey: `maintainer-outreach-report-${week}`, + }); + return { ...report, reportSent: delivery.sent, reason: delivery.reason || null }; +} \ No newline at end of file diff --git a/lib/maintainer-outreach-scheduler.js b/lib/maintainer-outreach-scheduler.js new file mode 100644 index 0000000..3d2a798 --- /dev/null +++ b/lib/maintainer-outreach-scheduler.js @@ -0,0 +1,64 @@ +import { getCosmosContainer } from './cosmos.js'; +import { getEngagementContainer } from './engagement-store.js'; +import { + MAINTAINER_OUTREACH_LIMIT, + selectMaintainerOutreachDrafts, + summarizeMaintainerOutreach, +} from './maintainer-outreach.js'; +import { + getMaintainerOutreachContainer, + listMaintainerOutreachRecords, + saveMaintainerOutreachDraft, +} from './maintainer-outreach-store.js'; + +async function loadCandidates(container) { + if (!container) throw new Error('Developer Cosmos container is required'); + const { resources } = await container.items.query({ + query: `SELECT TOP 500 c.login, c.name, c.topLanguage, c.score, c.totalStars, + c.totalCommits, c.followers, c.soReputation, c.claimed + FROM c + WHERE (NOT IS_DEFINED(c.nomination) OR c.nomination.status = "approved") + AND (NOT IS_DEFINED(c.claimed) OR c.claimed != true) + ORDER BY c.score DESC`, + }).fetchAll(); + return resources; +} + +export async function runMaintainerOutreachSchedule({ + now = new Date(), + limit = MAINTAINER_OUTREACH_LIMIT, + developers, + developerContainer = getCosmosContainer(), + outreachContainer = getMaintainerOutreachContainer(), +} = {}) { + if (!outreachContainer) throw new Error('Maintainer outreach Cosmos container is required'); + const [candidates, records] = await Promise.all([ + developers ? Promise.resolve(developers) : loadCandidates(developerContainer), + listMaintainerOutreachRecords(undefined, outreachContainer), + ]); + const drafts = selectMaintainerOutreachDrafts({ developers: candidates, records, now, limit }); + const queued = []; + for (const draft of drafts) queued.push(await saveMaintainerOutreachDraft(draft, outreachContainer)); + return { selected: drafts.length, queued: queued.length, delivery: 'manual_review_only' }; +} + +export async function getMaintainerOutreachReport({ + now = new Date(), + days = 30, + outreachContainer = getMaintainerOutreachContainer(), + engagementContainer = getEngagementContainer(), +} = {}) { + const records = await listMaintainerOutreachRecords(undefined, outreachContainer); + if (!engagementContainer) return summarizeMaintainerOutreach(records); + const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + const { resources } = await engagementContainer.items.query({ + query: `SELECT c.eventName, c.targetLogin FROM c + WHERE c.documentType = "engagement-event" + AND c.createdAt >= @since + AND c.eventName IN ("profile_viewed", "profile_claimed") + AND c.properties.source = "manual_outreach" + AND c.properties.campaign = "developer_activation"`, + parameters: [{ name: '@since', value: since }], + }).fetchAll(); + return summarizeMaintainerOutreach(records, resources); +} \ No newline at end of file diff --git a/lib/maintainer-outreach-store.js b/lib/maintainer-outreach-store.js new file mode 100644 index 0000000..1360abf --- /dev/null +++ b/lib/maintainer-outreach-store.js @@ -0,0 +1,111 @@ +import { getCosmosContainer } from './cosmos.js'; +import { followUpDueAt, MAINTAINER_OUTREACH_MAX_ATTEMPTS } from './maintainer-outreach.js'; + +const memoryRecords = new Map(); + +function loginOf(value) { + return String(value || '').trim().toLowerCase(); +} + +export function getMaintainerOutreachContainer() { + return getCosmosContainer(process.env.COSMOS_MAINTAINER_OUTREACH_CONTAINER || 'maintainer-outreach'); +} + +export async function getMaintainerOutreachRecord(login, container = getMaintainerOutreachContainer()) { + const normalizedLogin = loginOf(login); + if (!container) return memoryRecords.get(normalizedLogin) || null; + try { + const { resource } = await container.item(normalizedLogin, normalizedLogin).read(); + return resource || null; + } catch (error) { + if (error.code === 404 || error.statusCode === 404) return null; + throw error; + } +} + +export async function listMaintainerOutreachRecords(status, container = getMaintainerOutreachContainer()) { + if (!container) { + return [...memoryRecords.values()] + .filter(record => !status || record.status === status) + .sort((left, right) => right.selectedAt.localeCompare(left.selectedAt)); + } + const query = status + ? { + query: 'SELECT * FROM c WHERE c.documentType = "maintainer-outreach" AND c.status = @status ORDER BY c.selectedAt DESC', + parameters: [{ name: '@status', value: status }], + } + : 'SELECT * FROM c WHERE c.documentType = "maintainer-outreach" ORDER BY c.selectedAt DESC'; + const { resources } = await container.items.query(query).fetchAll(); + return resources; +} + +async function replaceRecord(record, container) { + if (!container) { + memoryRecords.set(record.login, record); + return record; + } + const { resource } = await container.item(record.id, record.login).replace(record, { + accessCondition: record._etag ? { type: 'IfMatch', condition: record._etag } : undefined, + }); + return resource; +} + +export async function saveMaintainerOutreachDraft(draft, container = getMaintainerOutreachContainer()) { + const existing = await getMaintainerOutreachRecord(draft.login, container); + const updatedAt = new Date().toISOString(); + if (!existing) { + const document = { ...draft, createdAt: updatedAt, updatedAt, attemptHistory: [] }; + if (!container) { + memoryRecords.set(document.login, document); + return document; + } + try { + const { resource } = await container.items.create(document); + return resource; + } catch (error) { + if (error.code === 409 || error.statusCode === 409) return getMaintainerOutreachRecord(draft.login, container); + throw error; + } + } + if (existing.status !== 'sent' || draft.attempt !== existing.attempt + 1) return existing; + return replaceRecord({ + ...existing, + ...draft, + createdAt: existing.createdAt, + updatedAt, + approvedAt: null, + approvedBy: null, + sentAt: null, + followUpDueAt: null, + attemptHistory: [ + ...(existing.attemptHistory || []), + { attempt: existing.attempt, sentAt: existing.sentAt }, + ], + }, container); +} + +export async function updateMaintainerOutreachStatus(login, action, actor, container = getMaintainerOutreachContainer(), now = new Date()) { + const existing = await getMaintainerOutreachRecord(login, container); + if (!existing) throw new Error(`Outreach record not found: ${login}`); + const timestamp = now.toISOString(); + let patch; + if (action === 'approve' && existing.status === 'pending') { + patch = { status: 'approved', approvedAt: timestamp, approvedBy: actor || null }; + } else if (action === 'reject' && ['pending', 'approved'].includes(existing.status)) { + patch = { status: 'rejected', rejectedAt: timestamp, rejectedBy: actor || null }; + } else if (action === 'sent' && existing.status === 'approved') { + patch = { + status: 'sent', + sentAt: timestamp, + sentBy: actor || null, + followUpDueAt: existing.attempt < MAINTAINER_OUTREACH_MAX_ATTEMPTS ? followUpDueAt(timestamp) : null, + }; + } else { + throw new Error(`Cannot ${action} outreach record in ${existing.status} state`); + } + return replaceRecord({ ...existing, ...patch, updatedAt: timestamp }, container); +} + +export function __resetMemoryMaintainerOutreachStoreForTests() { + memoryRecords.clear(); +} \ No newline at end of file diff --git a/lib/maintainer-outreach.js b/lib/maintainer-outreach.js new file mode 100644 index 0000000..d6823ec --- /dev/null +++ b/lib/maintainer-outreach.js @@ -0,0 +1,77 @@ +import { buildOutreachMessage, selectActivationCandidates } from './activation-campaign.js'; +import { getSiteUrl } from './site.js'; + +export const MAINTAINER_OUTREACH_LIMIT = 10; +export const MAINTAINER_OUTREACH_MAX_ATTEMPTS = 2; +export const MAINTAINER_OUTREACH_FOLLOW_UP_DAYS = 4; + +function loginOf(value) { + return String(value || '').trim().toLowerCase(); +} + +function profileUrl(developer, siteUrl) { + return `${siteUrl}/developer/${encodeURIComponent(developer.login)}?utm_source=manual_outreach&utm_medium=community&utm_campaign=developer_activation`; +} + +function followUpMessage(developer, siteUrl) { + const name = String(developer.name || developer.login).trim(); + return `Hi ${name}, one quick follow-up about your DevGlobe profile: ${profileUrl(developer, siteUrl)}\n\nIf it is not useful, no reply is needed and I will not follow up again. Feedback is welcome.`; +} + +function isFollowUpDue(record, now) { + return record?.status === 'sent' + && record.attempt < MAINTAINER_OUTREACH_MAX_ATTEMPTS + && record.followUpDueAt + && record.followUpDueAt <= now.toISOString(); +} + +export function selectMaintainerOutreachDrafts({ developers = [], records = [], now = new Date(), limit = MAINTAINER_OUTREACH_LIMIT, siteUrl = getSiteUrl() } = {}) { + const recordsByLogin = new Map(records.map(record => [loginOf(record.login), record])); + const candidates = selectActivationCandidates(developers, developers.length); + const drafts = []; + + for (const developer of candidates) { + const login = loginOf(developer.login); + const existing = recordsByLogin.get(login); + if (existing && !isFollowUpDue(existing, now)) continue; + const attempt = existing ? existing.attempt + 1 : 1; + drafts.push({ + id: login, + login, + documentType: 'maintainer-outreach', + status: 'pending', + attempt, + delivery: 'manual_review_only', + selectedAt: now.toISOString(), + profileUrl: profileUrl(developer, siteUrl), + message: attempt === 1 + ? buildOutreachMessage(developer, siteUrl) + : followUpMessage(developer, siteUrl), + }); + if (drafts.length >= Math.max(0, limit)) break; + } + + return drafts; +} + +export function followUpDueAt(sentAt) { + const due = new Date(sentAt); + due.setUTCDate(due.getUTCDate() + MAINTAINER_OUTREACH_FOLLOW_UP_DAYS); + return due.toISOString(); +} + +export function summarizeMaintainerOutreach(records = [], engagementEvents = []) { + const contacted = new Set(records + .filter(record => record.status === 'sent' || record.attempt > 1 || record.attemptHistory?.length) + .map(record => loginOf(record.login))); + const viewed = new Set(engagementEvents.filter(event => event.eventName === 'profile_viewed').map(event => loginOf(event.targetLogin))); + const claimed = new Set(engagementEvents.filter(event => event.eventName === 'profile_claimed').map(event => loginOf(event.targetLogin))); + return { + selected: records.length, + pending: records.filter(record => record.status === 'pending').length, + approved: records.filter(record => record.status === 'approved').length, + contacted: contacted.size, + profileViewed: [...viewed].filter(login => contacted.has(login)).length, + claimed: [...claimed].filter(login => contacted.has(login)).length, + }; +} \ No newline at end of file diff --git a/package.json b/package.json index 5cf4356..c74fe5e 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,13 @@ "setup-impact-history-container": "node scripts/setup-impact-history-container.js", "setup-introductions-container": "node scripts/setup-introductions-container.js", "setup-contacts-container": "node scripts/setup-contacts-container.js", + "setup-maintainer-outreach-container": "node scripts/setup-maintainer-outreach-container.js", "create-agent-key": "node scripts/create-agent-key.js", "review-nominations": "node scripts/review-nominations.js", "send-nomination-email": "node scripts/send-nomination-approval-email.js", "activation-campaign": "node scripts/generate-activation-campaign.js", + "outreach:queue": "node scripts/run-maintainer-outreach.js", + "outreach:review": "node scripts/review-maintainer-outreach.js", "seed-emulator": "node scripts/seed-emulator.js" }, "dependencies": { diff --git a/scripts/review-maintainer-outreach.js b/scripts/review-maintainer-outreach.js new file mode 100644 index 0000000..15c4845 --- /dev/null +++ b/scripts/review-maintainer-outreach.js @@ -0,0 +1,31 @@ +import 'dotenv/config'; +import { + getMaintainerOutreachContainer, + listMaintainerOutreachRecords, + updateMaintainerOutreachStatus, +} from '../lib/maintainer-outreach-store.js'; +import { getMaintainerOutreachReport } from '../lib/maintainer-outreach-scheduler.js'; + +const [command = 'list', value, actor] = process.argv.slice(2); +const container = getMaintainerOutreachContainer(); +if (!container) throw new Error('Cosmos DB is required for the maintainer outreach queue'); + +if (command === 'list') { + const records = await listMaintainerOutreachRecords(value, container); + console.log(JSON.stringify(records.map(record => ({ + login: record.login, + status: record.status, + attempt: record.attempt, + selectedAt: record.selectedAt, + profileUrl: record.profileUrl, + message: record.message, + })), null, 2)); +} else if (command === 'report') { + console.log(JSON.stringify(await getMaintainerOutreachReport({ outreachContainer: container }), null, 2)); +} else if (['approve', 'reject', 'sent'].includes(command)) { + if (!value) throw new Error(`${command} requires a GitHub login`); + const record = await updateMaintainerOutreachStatus(value, command, actor, container); + console.log(`${record.login} is now ${record.status} for attempt ${record.attempt}.`); +} else { + throw new Error('Command must be list, report, approve, reject, or sent'); +} \ No newline at end of file diff --git a/scripts/run-maintainer-outreach.js b/scripts/run-maintainer-outreach.js new file mode 100644 index 0000000..082ef16 --- /dev/null +++ b/scripts/run-maintainer-outreach.js @@ -0,0 +1,8 @@ +import 'dotenv/config'; +import { runMaintainerOutreachSchedule } from '../lib/maintainer-outreach-scheduler.js'; + +const limitArgument = process.argv.find(argument => argument.startsWith('--limit=')); +const requestedLimit = Number.parseInt(limitArgument?.slice('--limit='.length) || '10', 10); +const limit = Number.isInteger(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 10) : 10; +const summary = await runMaintainerOutreachSchedule({ limit }); +console.log(JSON.stringify(summary, null, 2)); \ No newline at end of file diff --git a/scripts/setup-maintainer-outreach-container.js b/scripts/setup-maintainer-outreach-container.js new file mode 100644 index 0000000..f8800e7 --- /dev/null +++ b/scripts/setup-maintainer-outreach-container.js @@ -0,0 +1,32 @@ +import 'dotenv/config'; +import { CosmosClient } from '@azure/cosmos'; + +const endpoint = process.env.COSMOS_ENDPOINT?.trim(); +const key = process.env.COSMOS_KEY?.trim(); +const databaseId = process.env.COSMOS_DATABASE || 'devglobe'; +const containerId = process.env.COSMOS_MAINTAINER_OUTREACH_CONTAINER || 'maintainer-outreach'; +if (!endpoint || !key) throw new Error('COSMOS_ENDPOINT and COSMOS_KEY are required'); + +const client = new CosmosClient({ endpoint, key }); +const database = client.database(databaseId); +const { resource, statusCode } = await database.containers.createIfNotExists({ + id: containerId, + partitionKey: { paths: ['/login'], kind: 'Hash' }, + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [ + { path: '/documentType/?' }, + { path: '/status/?' }, + { path: '/selectedAt/?' }, + { path: '/followUpDueAt/?' }, + ], + excludedPaths: [{ path: '/*' }], + compositeIndexes: [[ + { path: '/documentType', order: 'ascending' }, + { path: '/status', order: 'ascending' }, + { path: '/selectedAt', order: 'descending' }, + ]], + }, +}); +console.log(`${statusCode === 201 ? 'Created' : 'Verified'} private container ${databaseId}/${resource.id}.`); \ No newline at end of file diff --git a/tests/maintainer-outreach-report.test.js b/tests/maintainer-outreach-report.test.js new file mode 100644 index 0000000..02b4087 --- /dev/null +++ b/tests/maintainer-outreach-report.test.js @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildMaintainerOutreachReportEmail, + sendMaintainerOutreachReport, +} from '../lib/maintainer-outreach-report.js'; + +const report = { selected: 10, pending: 2, approved: 1, contacted: 5, profileViewed: 2, claimed: 1 }; + +test('builds an aggregate report with conversion rates and no profile identities', () => { + const message = buildMaintainerOutreachReportEmail(report); + assert.match(message.text, /Profile viewed: 2 \(40%\)/); + assert.match(message.text, /Claimed: 1 \(20%\)/); + assert.doesNotMatch(JSON.stringify(message), /login|recipient/i); +}); + +test('sends one idempotent operator report through the existing email provider', async () => { + let request; + const summary = await sendMaintainerOutreachReport({ + now: new Date('2026-09-07T13:30:00.000Z'), + recipient: 'operator@example.com', + loadReport: async () => report, + sendEmail: async value => { + request = value; + return { sent: true, id: 'email-1' }; + }, + }); + assert.equal(summary.reportSent, true); + assert.equal(request.to, 'operator@example.com'); + assert.equal(request.idempotencyKey, 'maintainer-outreach-report-2026-09-07'); +}); + +test('skips email cleanly when the operator recipient is absent', async () => { + const summary = await sendMaintainerOutreachReport({ + recipient: '', + loadReport: async () => report, + }); + assert.equal(summary.reportSent, false); + assert.equal(summary.reason, 'missing_recipient'); +}); \ No newline at end of file diff --git a/tests/maintainer-outreach-store.test.js b/tests/maintainer-outreach-store.test.js new file mode 100644 index 0000000..92efbbc --- /dev/null +++ b/tests/maintainer-outreach-store.test.js @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + __resetMemoryMaintainerOutreachStoreForTests, + getMaintainerOutreachRecord, + saveMaintainerOutreachDraft, + updateMaintainerOutreachStatus, +} from '../lib/maintainer-outreach-store.js'; + +const draft = { + id: 'maintainer', + login: 'maintainer', + documentType: 'maintainer-outreach', + status: 'pending', + attempt: 1, + delivery: 'manual_review_only', + selectedAt: '2026-09-03T12:00:00.000Z', + profileUrl: 'https://example.com/developer/maintainer', + message: 'Review this draft', +}; + +test.beforeEach(() => __resetMemoryMaintainerOutreachStoreForTests()); + +test('creates drafts idempotently and enforces manual state transitions', async () => { + await saveMaintainerOutreachDraft(draft, null); + await saveMaintainerOutreachDraft({ ...draft, message: 'Duplicate' }, null); + assert.equal((await getMaintainerOutreachRecord('maintainer', null)).message, 'Review this draft'); + + await updateMaintainerOutreachStatus('maintainer', 'approve', 'operator', null, new Date('2026-09-03T13:00:00.000Z')); + const sent = await updateMaintainerOutreachStatus('maintainer', 'sent', 'operator', null, new Date('2026-09-03T14:00:00.000Z')); + assert.equal(sent.status, 'sent'); + assert.equal(sent.followUpDueAt, '2026-09-07T14:00:00.000Z'); + await assert.rejects(() => updateMaintainerOutreachStatus('maintainer', 'sent', 'operator', null), /Cannot sent/); +}); + +test('preserves first-attempt history when a follow-up draft becomes due', async () => { + await saveMaintainerOutreachDraft(draft, null); + await updateMaintainerOutreachStatus('maintainer', 'approve', 'operator', null); + await updateMaintainerOutreachStatus('maintainer', 'sent', 'operator', null, new Date('2026-09-03T14:00:00.000Z')); + const followUp = await saveMaintainerOutreachDraft({ + ...draft, + attempt: 2, + selectedAt: '2026-09-07T14:00:00.000Z', + message: 'One final follow-up', + }, null); + assert.equal(followUp.status, 'pending'); + assert.equal(followUp.attempt, 2); + assert.deepEqual(followUp.attemptHistory, [{ attempt: 1, sentAt: '2026-09-03T14:00:00.000Z' }]); +}); + +test('rejection is terminal', async () => { + await saveMaintainerOutreachDraft(draft, null); + const rejected = await updateMaintainerOutreachStatus('maintainer', 'reject', 'operator', null); + assert.equal(rejected.status, 'rejected'); + await assert.rejects(() => updateMaintainerOutreachStatus('maintainer', 'approve', 'operator', null), /Cannot approve/); +}); \ No newline at end of file diff --git a/tests/maintainer-outreach.test.js b/tests/maintainer-outreach.test.js new file mode 100644 index 0000000..a81c49b --- /dev/null +++ b/tests/maintainer-outreach.test.js @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + followUpDueAt, + selectMaintainerOutreachDrafts, + summarizeMaintainerOutreach, +} from '../lib/maintainer-outreach.js'; + +const now = new Date('2026-09-03T12:00:00.000Z'); +const developers = Array.from({ length: 12 }, (_, index) => ({ + login: `maintainer-${index + 1}`, + name: `Maintainer ${index + 1}`, + score: 100 - index, + totalStars: 500 - index, +})); + +test('selects ten review-only tracked drafts without re-queueing existing logins', () => { + const drafts = selectMaintainerOutreachDrafts({ + developers, + records: [{ login: 'maintainer-1', status: 'pending', attempt: 1 }], + now, + siteUrl: 'https://example.com', + }); + + assert.equal(drafts.length, 10); + assert.equal(drafts[0].login, 'maintainer-2'); + assert.equal(drafts[0].delivery, 'manual_review_only'); + assert.match(drafts[0].message, /utm_source=manual_outreach/); + assert.match(drafts[0].message, /utm_campaign=developer_activation/); + assert.doesNotMatch(JSON.stringify(drafts), /email|recipient|sendAt/i); +}); + +test('prepares only one follow-up after four days', () => { + const dueRecord = { + login: 'maintainer-1', + status: 'sent', + attempt: 1, + followUpDueAt: '2026-09-03T11:59:00.000Z', + }; + const [followUp] = selectMaintainerOutreachDrafts({ developers, records: [dueRecord], now, limit: 1, siteUrl: 'https://example.com' }); + assert.equal(followUp.login, 'maintainer-1'); + assert.equal(followUp.attempt, 2); + assert.match(followUp.message, /will not follow up again/); + + const noThirdAttempt = selectMaintainerOutreachDrafts({ + developers, + records: [{ ...dueRecord, attempt: 2 }], + now, + limit: 1, + }); + assert.equal(noThirdAttempt[0].login, 'maintainer-2'); + assert.equal(noThirdAttempt[0].attempt, 1); + assert.equal(followUpDueAt('2026-09-03T12:00:00.000Z'), '2026-09-07T12:00:00.000Z'); +}); + +test('summarizes only contacted profiles in funnel outcomes', () => { + const summary = summarizeMaintainerOutreach([ + { login: 'maintainer-1', status: 'sent' }, + { login: 'maintainer-2', status: 'pending' }, + ], [ + { targetLogin: 'maintainer-1', eventName: 'profile_viewed' }, + { targetLogin: 'maintainer-1', eventName: 'profile_claimed' }, + { targetLogin: 'someone-else', eventName: 'profile_claimed' }, + ]); + assert.deepEqual(summary, { selected: 2, pending: 1, approved: 0, contacted: 1, profileViewed: 1, claimed: 1 }); +}); \ No newline at end of file