From e785439d8e9131c5c37fd0d37d27072086929035 Mon Sep 17 00:00:00 2001 From: sajeetharan Date: Sun, 16 Aug 2026 00:47:29 +0530 Subject: [PATCH 1/3] feat: add developer watchlists --- README.md | 1 + app/api/watchlist/developers/route.js | 54 ++++++++++++++++++++++ app/page.jsx | 1 + components/DetailPanel.jsx | 62 ++++++++++++++++++++++++- docs/prd/developer-watchlists.md | 66 +++++++++++++++++++++++++++ lib/watchlist-store.js | 44 +++++++++++++++++- package.json | 1 + scripts/setup-watchlist-container.js | 27 +++++++++++ styles/main.css | 34 ++++++++++++++ tests/watchlist-store.test.js | 26 +++++++++++ 10 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 app/api/watchlist/developers/route.js create mode 100644 docs/prd/developer-watchlists.md create mode 100644 scripts/setup-watchlist-container.js create mode 100644 tests/watchlist-store.test.js diff --git a/README.md b/README.md index c9175d3..43ff5c0 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ Required environment variables: | `ACTIVITY_INGEST_SECRET` | Bearer secret for the activity collector endpoint | | `RESEND_API_KEY` | Optional Resend API key for claim and approval emails | | `EMAIL_FROM` | Sender on a domain verified by Resend | +| `COSMOS_WATCHLIST_CONTAINER` | Optional private watchlist container name (default: `watchlists`) | | `CRON_SECRET` | Bearer token used by Vercel Cron for the weekly digest endpoint | | `EMAIL_PREFERENCE_SECRET` | HMAC secret for weekly-email unsubscribe links; defaults to `SESSION_SECRET` | diff --git a/app/api/watchlist/developers/route.js b/app/api/watchlist/developers/route.js new file mode 100644 index 0000000..dbc1770 --- /dev/null +++ b/app/api/watchlist/developers/route.js @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '../../../../lib/auth.js'; +import { + followEntity, + getWatchlist, + normalizeDeveloperFollow, + unfollowEntity, +} from '../../../../lib/watchlist-store.js'; + +export async function GET() { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + try { + const watchlist = await getWatchlist(session.login); + return NextResponse.json( + { developers: watchlist.follows.developers }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + console.error('Developer watchlist query failed:', error.message); + return NextResponse.json({ error: 'Unable to load followed developers' }, { status: 503 }); + } +} + +async function updateFollow(request, remove) { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + let login; + try { + const body = await request.json(); + login = normalizeDeveloperFollow(body.login); + const watchlist = remove + ? await unfollowEntity(session.login, 'developers', login) + : await followEntity(session.login, 'developers', login); + return NextResponse.json({ developers: watchlist.follows.developers }); + } catch (error) { + const validationError = /Invalid GitHub login|own profile|follow limit/.test(error.message); + if (!validationError) console.error('Developer watchlist update failed:', error.message); + return NextResponse.json( + { error: validationError ? error.message : 'Unable to update followed developers' }, + { status: validationError ? 400 : 500 }, + ); + } +} + +export async function POST(request) { + return updateFollow(request, false); +} + +export async function DELETE(request) { + return updateFollow(request, true); +} \ No newline at end of file diff --git a/app/page.jsx b/app/page.jsx index 380febf..c9e6310 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -482,6 +482,7 @@ export default function Home() { onClose={handleCloseDetail} onCardGenerated={recordCardActivity} claimedLogins={claimedLogins} + user={user} openCardOnMount={cardRequest > 0} claimSuccess={cardContext === 'claim'} /> diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx index bb8918b..728b18f 100644 --- a/components/DetailPanel.jsx +++ b/components/DetailPanel.jsx @@ -10,9 +10,11 @@ import { classifyAgent } from '../lib/agent-class.js'; import { AI_TOOLS } from '../lib/ai-profile.js'; import SpecialTags from './SpecialTags.jsx'; -export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogins, openCardOnMount = false, claimSuccess = false }) { +export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogins, user, openCardOnMount = false, claimSuccess = false }) { const [fullData, setFullData] = useState(null); const [showCard, setShowCard] = useState(false); + const [followState, setFollowState] = useState('idle'); + const [followError, setFollowError] = useState(''); const radarRef = useRef(null); const heatmapRef = useRef(null); const langRef = useRef(null); @@ -27,6 +29,49 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi if (openCardOnMount) setShowCard(true); }, [openCardOnMount]); + useEffect(() => { + if (!user || user.login.toLowerCase() === dev.login.toLowerCase()) return; + let cancelled = false; + setFollowState('loading'); + fetch('/api/watchlist/developers', { cache: 'no-store' }) + .then(async response => { + if (!response.ok) throw new Error('Unable to load follows'); + const result = await response.json(); + if (!cancelled) { + setFollowState(result.developers.includes(dev.login.toLowerCase()) ? 'following' : 'not-following'); + } + }) + .catch(() => { + if (!cancelled) setFollowState('not-following'); + }); + return () => { cancelled = true; }; + }, [dev.login, user]); + + const handleFollow = async () => { + if (!user) { + window.location.assign('/api/auth/github'); + return; + } + const wasFollowing = followState === 'following'; + setFollowState('saving'); + setFollowError(''); + try { + const response = await fetch('/api/watchlist/developers', { + method: wasFollowing ? 'DELETE' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login: dev.login }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Unable to update follow'); + const following = result.developers.includes(dev.login.toLowerCase()); + setFollowState(following ? 'following' : 'not-following'); + track(following ? 'developer_followed' : 'developer_unfollowed', { login: dev.login }); + } catch (error) { + setFollowState(wasFollowing ? 'following' : 'not-following'); + setFollowError(error.message); + } + }; + // Fetch full details on mount useEffect(() => { let cancelled = false; @@ -129,6 +174,20 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi {SCORE_METHODOLOGY.short}

+ {user?.login.toLowerCase() !== dev.login.toLowerCase() && ( + + )} GitHub ↗ {merged.soUserId && ( StackOverflow ↗ @@ -146,6 +205,7 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi Generate Identity Card
+ {followError &&
{followError}
} diff --git a/docs/prd/developer-watchlists.md b/docs/prd/developer-watchlists.md new file mode 100644 index 0000000..fd5e853 --- /dev/null +++ b/docs/prd/developer-watchlists.md @@ -0,0 +1,66 @@ +# PRD: Developer Watchlists + +**Status:** MVP implementation +**Issue:** [#113](https://github.com/sajeetharan/devglobe/issues/113) +**Priority:** P0 +**Depends on:** [#127](https://github.com/sajeetharan/devglobe/issues/127) +**Last updated:** 2026-08-16 + +## Summary + +DevGlobe will let signed-in users privately follow developers from a profile. These explicit follows seed the personalized activity feed and future opt-in digest, creating a useful record of what changed in a developer's network. + +## Problem + +Discovery currently ends when a profile is closed. Users cannot retain interesting developers or return to changes from people they care about, so each visit starts from scratch. + +## Goals + +- Let a signed-in user follow or unfollow a public developer from the profile panel. +- Keep watchlists private and scoped to the authenticated GitHub login. +- Feed existing personalization through `follows.developers` without duplicating storage. +- Normalize GitHub logins, prevent self-following, and bound the MVP list to 100 developers. +- Measure follow and unfollow intent for retention analysis. + +## Non-goals + +- Public follower counts or social popularity rankings. +- Following private or pending profiles through a direct public UI. +- Push or email notifications in this slice; those are covered by #22 and #165. +- Full management UI for projects, languages, and countries. + +## User experience + +- A signed-in visitor sees **Follow** on another developer's profile. +- Activating it updates in place to **Following**. Activating **Following** unfollows. +- A signed-out visitor sees **Follow** and is sent to GitHub sign-in when activating it. +- The control is hidden on the user's own profile. +- Loading and failed updates do not optimistically claim success. + +## API + +- `GET /api/watchlist/developers` returns `{ developers: string[] }` for the authenticated user. +- `POST /api/watchlist/developers` with `{ login }` follows a developer. +- `DELETE /api/watchlist/developers` with `{ login }` unfollows a developer. +- All methods return `401` without a valid session and responses are never publicly cached. + +## Data and privacy + +One private document is stored per authenticated login in the `watchlists` container, partitioned by `/id`. Developer logins are canonical lowercase strings. The watchlist is never included in public profile, search, card, or MCP responses. + +Existing documents are replaced with an ETag precondition so concurrent feed read-state and follow updates fail rather than silently overwriting each other. + +## Success metrics + +- Percentage of signed-in users following at least one and at least three developers. +- Follow conversion from developer profile views. +- D7 retention difference between followers and non-followers. +- Personalized-feed opens among users with follows. + +## Acceptance criteria + +- Follow state persists across sessions when Cosmos DB is configured. +- Duplicate casing cannot create duplicate follows. +- Self-follow, invalid logins, and more than 100 follows are rejected. +- The existing personalized feed matches newly followed developers. +- Store tests, the full test suite, and production build pass. \ No newline at end of file diff --git a/lib/watchlist-store.js b/lib/watchlist-store.js index 82fbd06..d41e5be 100644 --- a/lib/watchlist-store.js +++ b/lib/watchlist-store.js @@ -4,6 +4,29 @@ const memoryWatchlists = new Map(); const FOLLOW_CATEGORIES = ['developers', 'projects', 'languages', 'countries']; const MUTE_ENTITY_TYPES = ['developer', 'project', 'language', 'country']; +export const MAX_DEVELOPER_FOLLOWS = 100; + +export function normalizeDeveloperFollow(value) { + const login = String(value || '').trim().replace(/^@/, '').toLowerCase(); + if (!/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/.test(login)) { + throw new Error('Invalid GitHub login'); + } + return login; +} + +export function updateDeveloperFollows(follows, value, { remove = false, ownerLogin = '' } = {}) { + const login = normalizeDeveloperFollow(value); + const owner = ownerLogin ? normalizeDeveloperFollow(ownerLogin) : ''; + if (!remove && login === owner) throw new Error('You cannot follow your own profile'); + + const current = new Set((follows || []).map(normalizeDeveloperFollow)); + if (remove) current.delete(login); + else current.add(login); + if (current.size > MAX_DEVELOPER_FOLLOWS) { + throw new Error(`Developer follow limit is ${MAX_DEVELOPER_FOLLOWS}`); + } + return [...current]; +} function getWatchlistContainer() { return getCosmosContainer(process.env.COSMOS_WATCHLIST_CONTAINER || 'watchlists'); @@ -14,6 +37,7 @@ function emptyWatchlist(login) { id: login, login, documentType: 'watchlist', + schemaVersion: 1, follows: { developers: [], projects: [], languages: [], countries: [] }, mutes: { entities: [], eventTypes: [] }, readState: { readThrough: null, readIds: [] }, @@ -39,12 +63,22 @@ export async function getWatchlist(login) { } async function saveWatchlist(watchlist) { - const document = { ...watchlist, updatedAt: new Date().toISOString() }; + const document = { ...watchlist, schemaVersion: 1, updatedAt: new Date().toISOString() }; const container = getWatchlistContainer(); if (!container) { memoryWatchlists.set(document.login, document); return document; } + if (watchlist._etag) { + const etag = watchlist._etag; + for (const key of Object.keys(document)) { + if (key.startsWith('_')) delete document[key]; + } + const { resource } = await container.item(document.id, document.id).replace(document, { + accessCondition: { type: 'IfMatch', condition: etag }, + }); + return resource; + } const { resource } = await container.items.upsert(document); return resource; } @@ -54,6 +88,10 @@ export async function followEntity(login, category, value) { throw new Error(`Invalid follow category: ${category}`); } const watchlist = await getWatchlist(login); + if (category === 'developers') { + watchlist.follows.developers = updateDeveloperFollows(watchlist.follows.developers, value, { ownerLogin: login }); + return saveWatchlist(watchlist); + } const set = new Set(watchlist.follows[category]); set.add(value); watchlist.follows[category] = [...set]; @@ -65,6 +103,10 @@ export async function unfollowEntity(login, category, value) { throw new Error(`Invalid follow category: ${category}`); } const watchlist = await getWatchlist(login); + if (category === 'developers') { + watchlist.follows.developers = updateDeveloperFollows(watchlist.follows.developers, value, { remove: true }); + return saveWatchlist(watchlist); + } watchlist.follows[category] = watchlist.follows[category].filter(entry => entry !== value); return saveWatchlist(watchlist); } diff --git a/package.json b/package.json index 481fc4c..0d1d3e3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "rescore-developers": "node scripts/rescore-developers.js", "populate-special-tags": "node scripts/populate-special-tags.js", "setup-activity-container": "node scripts/setup-activity-container.js", + "setup-watchlist-container": "node scripts/setup-watchlist-container.js", "setup-introductions-container": "node scripts/setup-introductions-container.js", "setup-contacts-container": "node scripts/setup-contacts-container.js", "create-agent-key": "node scripts/create-agent-key.js", diff --git a/scripts/setup-watchlist-container.js b/scripts/setup-watchlist-container.js new file mode 100644 index 0000000..c4a3df1 --- /dev/null +++ b/scripts/setup-watchlist-container.js @@ -0,0 +1,27 @@ +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_WATCHLIST_CONTAINER || 'watchlists'; + +if (!endpoint || !key) { + console.error('COSMOS_ENDPOINT and COSMOS_KEY are required.'); + process.exit(1); +} + +const client = new CosmosClient({ endpoint, key }); +const database = client.database(databaseId); +const { resource, statusCode } = await database.containers.createIfNotExists({ + id: containerId, + partitionKey: { paths: ['/id'], kind: 'Hash' }, + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [], + excludedPaths: [{ path: '/*' }], + }, +}); + +console.log(`${statusCode === 201 ? 'Created' : 'Verified'} private container ${databaseId}/${resource.id}.`); \ No newline at end of file diff --git a/styles/main.css b/styles/main.css index 9a3e314..c6a2ce7 100644 --- a/styles/main.css +++ b/styles/main.css @@ -2047,6 +2047,8 @@ body { .detail-header__links { display: flex; + align-items: center; + flex-wrap: wrap; gap: 8px; margin-top: 8px; } @@ -2061,6 +2063,38 @@ body { text-decoration: underline; } +.btn--follow { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 32px; + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--overlay-subtle); + color: var(--text-primary); + font: 600 12px var(--font); + cursor: pointer; +} + +.btn--follow:hover:not(:disabled), +.btn--follow-active { + border-color: rgba(46, 164, 79, 0.55); + background: rgba(46, 164, 79, 0.14); + color: #2ea44f; +} + +.btn--follow:disabled { + cursor: wait; + opacity: 0.65; +} + +.detail-header__follow-error { + margin-top: 6px; + color: #ef4444; + font-size: 11px; +} + .ai-collaboration { margin: 0 0 24px; padding: 16px; diff --git a/tests/watchlist-store.test.js b/tests/watchlist-store.test.js new file mode 100644 index 0000000..22078d7 --- /dev/null +++ b/tests/watchlist-store.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MAX_DEVELOPER_FOLLOWS, + normalizeDeveloperFollow, + updateDeveloperFollows, +} from '../lib/watchlist-store.js'; + +test('normalizes developer follows and removes duplicate casing', () => { + assert.equal(normalizeDeveloperFollow(' @OctoCat '), 'octocat'); + assert.deepEqual(updateDeveloperFollows(['OctoCat'], '@octocat', { ownerLogin: 'viewer' }), ['octocat']); +}); + +test('rejects invalid and self follows', () => { + assert.throws(() => normalizeDeveloperFollow('invalid--login'), /Invalid GitHub login/); + assert.throws( + () => updateDeveloperFollows([], 'Viewer', { ownerLogin: 'viewer' }), + /own profile/, + ); +}); + +test('removes follows and enforces the developer follow limit', () => { + assert.deepEqual(updateDeveloperFollows(['octocat'], 'OCTOCAT', { remove: true }), []); + const follows = Array.from({ length: MAX_DEVELOPER_FOLLOWS }, (_, index) => `dev-${index}`); + assert.throws(() => updateDeveloperFollows(follows, 'one-more'), /follow limit/); +}); \ No newline at end of file From 0e014e75e0fe910b9f59b620968a1fbc8489ac54 Mon Sep 17 00:00:00 2001 From: sajeetharan Date: Sun, 16 Aug 2026 09:37:48 +0530 Subject: [PATCH 2/3] fix: allow preview deployment comments --- .github/workflows/deploy.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f12f7fb..eb61259 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,9 @@ env: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v4 @@ -47,7 +50,7 @@ jobs: - name: Comment PR with Preview URL if: github.event_name == 'pull_request' && env.VERCEL_TOKEN != '' && env.VERCEL_ORG_ID != '' && env.VERCEL_PROJECT_ID != '' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | github.rest.issues.createComment({ From 02c955ea0204affe678e16257887774b7c238ff5 Mon Sep 17 00:00:00 2001 From: sajeetharan Date: Sun, 16 Aug 2026 09:52:23 +0530 Subject: [PATCH 3/3] chore: update actions to Node 24 --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eb61259..bb01550 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,10 +19,10 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 22 cache: npm