Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ env:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
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
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
54 changes: 54 additions & 0 deletions app/api/watchlist/developers/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions app/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ export default function Home() {
onClose={handleCloseDetail}
onCardGenerated={recordCardActivity}
claimedLogins={claimedLogins}
user={user}
openCardOnMount={cardRequest > 0}
claimSuccess={cardContext === 'claim'}
/>
Expand Down
62 changes: 61 additions & 1 deletion components/DetailPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -129,6 +174,20 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
{SCORE_METHODOLOGY.short}
</p>
<div className="detail-header__links">
{user?.login.toLowerCase() !== dev.login.toLowerCase() && (
<button
type="button"
className={`btn btn--follow${followState === 'following' ? ' btn--follow-active' : ''}`}
onClick={handleFollow}
disabled={followState === 'loading' || followState === 'saving'}
aria-pressed={followState === 'following'}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{followState === 'following' ? <path d="m5 12 4 4L19 6" /> : <><path d="M15 19a6 6 0 00-12 0" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6M22 11h-6" /></>}
</svg>
{followState === 'following' ? 'Following' : followState === 'saving' ? 'Saving...' : 'Follow'}
</button>
)}
<a href={merged.githubUrl || `https://github.com/${dev.login}`} target="_blank" rel="noopener noreferrer">GitHub ↗</a>
{merged.soUserId && (
<a href={`https://stackoverflow.com/users/${merged.soUserId}`} target="_blank" rel="noreferrer">StackOverflow ↗</a>
Expand All @@ -146,6 +205,7 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
Generate Identity Card
</button>
</div>
{followError && <div className="detail-header__follow-error" role="status">{followError}</div>}
</div>
</div>
</div>
Expand Down
66 changes: 66 additions & 0 deletions docs/prd/developer-watchlists.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 43 additions & 1 deletion lib/watchlist-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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: [] },
Expand All @@ -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;
}
Expand All @@ -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];
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions scripts/setup-watchlist-container.js
Original file line number Diff line number Diff line change
@@ -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}.`);
Loading