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
60 changes: 60 additions & 0 deletions app/api/saved-searches/[id]/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { getSession } from '../../../../lib/auth.js';
import { ALERT_FREQUENCIES } from '../../../../lib/saved-search.js';
import { deleteSavedSearch, getSavedSearch, updateSavedSearch } from '../../../../lib/saved-search-store.js';

export async function PATCH(request, { params }) {
const session = await getSession();
if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });

const { id } = await params;
const existing = await getSavedSearch(session.login, id);
if (!existing) return NextResponse.json({ error: 'Saved search not found' }, { status: 404 });

let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}

const patch = {};
if (typeof body.name === 'string') {
const name = body.name.trim().slice(0, 80);
if (!name) return NextResponse.json({ error: 'name cannot be empty' }, { status: 400 });
patch.name = name;
}
if (body.alert) {
if (!ALERT_FREQUENCIES.includes(body.alert.frequency)) {
return NextResponse.json({ error: 'Invalid alert frequency' }, { status: 400 });
}
patch.alert = { frequency: body.alert.frequency, enabled: body.alert.frequency !== 'off' };
}

if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
}

try {
const search = await updateSavedSearch(session.login, id, patch);
return NextResponse.json({ search });
} catch (error) {
console.error('Update saved search failed:', error.message);
return NextResponse.json({ error: 'Unable to update saved search' }, { status: 500 });
}
}

export async function DELETE(request, { params }) {
const session = await getSession();
if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });

const { id } = await params;
try {
const deleted = await deleteSavedSearch(session.login, id);
if (!deleted) return NextResponse.json({ error: 'Saved search not found' }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Delete saved search failed:', error.message);
return NextResponse.json({ error: 'Unable to delete saved search' }, { status: 500 });
}
}
34 changes: 34 additions & 0 deletions app/api/saved-searches/[id]/run/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { getSession } from '../../../../../lib/auth.js';
import { diffNewMatches } from '../../../../../lib/saved-search.js';
import { runSavedSearch } from '../../../../../lib/saved-search-run.js';
import { getSavedSearch, updateSavedSearch } from '../../../../../lib/saved-search-store.js';

export async function POST(request, { params }) {
const session = await getSession();
if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });

const { id } = await params;
const search = await getSavedSearch(session.login, id);
if (!search) return NextResponse.json({ error: 'Saved search not found' }, { status: 404 });

try {
const results = await runSavedSearch(search.criteria);
const currentLogins = results.map(developer => developer.login);
const { newLogins, updatedSeenLogins } = diffNewMatches(currentLogins, search.seenLogins);

const updated = await updateSavedSearch(session.login, id, {
seenLogins: updatedSeenLogins,
lastRunAt: new Date().toISOString(),
});

return NextResponse.json({
results,
newMatches: results.filter(developer => newLogins.includes(developer.login)),
lastRunAt: updated.lastRunAt,
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
console.error('Run saved search failed:', error.message);
return NextResponse.json({ error: 'Unable to run saved search' }, { status: 500 });
}
}
39 changes: 39 additions & 0 deletions app/api/saved-searches/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { getSession } from '../../../lib/auth.js';
import { SavedSearchValidationError, normalizeSavedSearch } from '../../../lib/saved-search.js';
import { createSavedSearch, listSavedSearches } from '../../../lib/saved-search-store.js';

export async function GET() {
const session = await getSession();
if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });

try {
const searches = await listSavedSearches(session.login);
return NextResponse.json({ searches }, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
console.error('List saved searches failed:', error.message);
return NextResponse.json({ error: 'Unable to load saved searches' }, { status: 500 });
}
}

export async function POST(request) {
const session = await getSession();
if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });

let normalized;
try {
normalized = normalizeSavedSearch(await request.json());
} catch (error) {
const message = error instanceof SavedSearchValidationError ? error.message : 'Invalid request body';
return NextResponse.json({ error: message }, { status: 400 });
}

try {
const search = await createSavedSearch(session.login, normalized);
return NextResponse.json({ search }, { status: 201 });
} catch (error) {
const status = error.status || 500;
console.error('Create saved search failed:', error.message);
return NextResponse.json({ error: error.message || 'Unable to save search' }, { status });
}
}
55 changes: 55 additions & 0 deletions docs/prd/saved-searches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# PRD: Saved Developer Searches with New-Match Alerts

**Issue:** [#166](https://github.com/sajeetharan/devglobe/issues/166)
**Related:** [#120](https://github.com/sajeetharan/devglobe/issues/120) (restore recent discovery session)
**Status:** MVP implementation

## Summary

Signed-in users can save a search (free-text query and/or structured filters: country, language, minimum score), re-run it on demand, rename or delete it, and opt in to a per-search alert frequency for when new public developers start matching.

## Acceptance criteria covered

- **Save, rename, run, and delete search criteria** — `POST /api/saved-searches`, `PATCH /api/saved-searches/[id]`, `POST /api/saved-searches/[id]/run`, `DELETE /api/saved-searches/[id]`.
- **Support current text/vector/hybrid mode and structured filters** — `lib/saved-search-run.js` fetches candidates using the same text/vector/hybrid patterns as `/api/search`, then `lib/saved-search.js` applies country/language/minScore filters and the free-text query locally.
- **New-match detection is incremental and deduplicated** — each saved search persists a `seenLogins` set; `diffNewMatches()` only reports logins not already in that set, then merges them in, so repeated runs never re-report the same match (see `tests/saved-search.test.js`).
- **Alerts are opt-in with per-search frequency controls** — `alert.frequency` is `off` by default; `daily` / `weekly` can be set per search via `PATCH`.
- **Private or pending profiles never appear** — `isPubliclyVisible()` mirrors the `PUBLIC_FILTER` predicate already used by `/api/search` and `/api/developers` (excludes any profile with `nomination.status` other than `approved`), applied as a local guard regardless of where the candidate pool came from.

## Data model

```json
{
"id": "torvalds:3f1c...",
"documentType": "saved-search",
"login": "torvalds",
"searchId": "3f1c...",
"name": "Rust devs in Germany",
"criteria": {
"query": "rust",
"mode": "text",
"filters": { "country": "Germany", "language": null, "minScore": null }
},
"alert": { "frequency": "weekly", "enabled": true },
"seenLogins": ["torvalds", "gaearon"],
"lastRunAt": "2026-08-15T09:00:00.000Z",
"createdAt": "2026-08-15T08:00:00.000Z",
"updatedAt": "2026-08-15T09:00:00.000Z"
}
```

A user may have up to `MAX_SAVED_SEARCHES_PER_USER` (25) saved searches. `seenLogins` is capped at `MAX_SEEN_LOGINS` (2000, oldest dropped) so it can't grow unbounded over years of runs.

## API contract

- `GET /api/saved-searches` — auth required. Returns `{ searches }` for the signed-in user.
- `POST /api/saved-searches` — auth required. Body: `{ name, criteria: { query?, mode?, filters?: { country?, language?, minScore? } }, alert?: { frequency } }`. At least one of `query`/`country`/`language`/`minScore` is required.
- `PATCH /api/saved-searches/[id]` — auth required. Body: `{ name? }` and/or `{ alert: { frequency } }`.
- `DELETE /api/saved-searches/[id]` — auth required.
- `POST /api/saved-searches/[id]/run` — auth required. Executes the saved criteria now, updates `seenLogins`/`lastRunAt`, and returns `{ results, newMatches, lastRunAt }`.

## Out of scope for this PR

- **Alert delivery** (email/push notification when new matches appear on the `daily`/`weekly` cadence). This PR defines the opt-in frequency setting and the incremental new-match detection primitive (`diffNewMatches`) a scheduled job would call per saved search; the job itself, and the delivery channel, are a follow-up — the same split used for the personalized feed's event-generator jobs (#127 → #111).
- **Saved searches UI** (a "Save this search" button in `SearchBar.jsx`/`Leaderboard.jsx`, and a management screen). This PR ships the API contract first; the UI is a natural follow-up once the contract is reviewed.
- **Vector/hybrid mode when Azure OpenAI isn't configured** — `lib/saved-search-run.js` degrades to text mode in that case rather than failing the run, matching how `/api/search` already requires OpenAI config for those modes.
131 changes: 131 additions & 0 deletions lib/saved-search-run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { CosmosClient } from '@azure/cosmos';
import { promises as fs } from 'fs';
import path from 'path';
import { runSavedSearchAgainstCandidates } from './saved-search.js';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
const OPENAI_KEY = process.env.AZURE_OPENAI_KEY;
const EMBEDDING_DEPLOYMENT = process.env.EMBEDDING_DEPLOYMENT || 'text-embedding-3-small';
const DATABASE = process.env.COSMOS_DATABASE || 'devglobe';
const CONTAINER = process.env.COSMOS_CONTAINER || 'developers';

// Same predicate as /api/search and /api/developers: excludes pending/rejected
// self-nominations. Legacy documents with no `nomination` field stay public.
const PUBLIC_FILTER = "(NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')";
const CANDIDATE_FIELDS = 'c.id, c.login, c.name, c.avatarUrl, c.location, c.topLanguage, c.score, c.totalStars, c.followers, c.nomination';
const CANDIDATE_POOL_SIZE = 200;

async function getEmbedding(text) {
const url = `${OPENAI_ENDPOINT}/openai/deployments/${EMBEDDING_DEPLOYMENT}/embeddings?api-version=2024-02-01`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': OPENAI_KEY },
body: JSON.stringify({ input: [text] }),
});
const data = await res.json();
return data.data[0].embedding;
}

async function getSampleCandidates() {
const filePath = path.join(process.cwd(), 'data', 'developers-sample.json');
const raw = await fs.readFile(filePath, 'utf-8');
return JSON.parse(raw);
}

/**
* Fetch a candidate pool matching the saved search's mode + free-text query
* (before structured filters/privacy are applied — see runSavedSearch below).
* Falls back to bundled sample data when Cosmos isn't configured, same as
* every other route in this repo.
*/
async function fetchCandidates({ query, mode }) {
if (!COSMOS_ENDPOINT || !COSMOS_KEY) {
return getSampleCandidates();
}

const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
const container = client.database(DATABASE).container(CONTAINER);

// No free-text query: structured-filters-only search. Pull a broad, scored
// candidate pool and let lib/saved-search.js narrow it down locally.
if (!query) {
const { resources } = await container.items.query({
query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS} FROM c WHERE ${PUBLIC_FILTER} ORDER BY c.score DESC`,
}).fetchAll();
return resources;
}

if (mode === 'vector' || mode === 'hybrid') {
if (!OPENAI_ENDPOINT || !OPENAI_KEY) {
// Degrade to text mode rather than failing the whole saved search run.
mode = 'text';
}
}

if (mode === 'vector') {
const embedding = await getEmbedding(query);
const { resources } = await container.items.query({
query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS}
FROM c WHERE ${PUBLIC_FILTER} ORDER BY VectorDistance(c.embedding, @embedding)`,
parameters: [{ name: '@embedding', value: embedding }],
}).fetchAll();
return resources;
}

if (mode === 'hybrid') {
const searchTerm = query.toLowerCase();
const embedding = await getEmbedding(query);
const [vectorRes, textRes] = await Promise.all([
container.items.query({
query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS}
FROM c WHERE ${PUBLIC_FILTER} ORDER BY VectorDistance(c.embedding, @embedding)`,
parameters: [{ name: '@embedding', value: embedding }],
}).fetchAll(),
container.items.query({
query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS}
FROM c
WHERE (CONTAINS(LOWER(c.login), @q) OR CONTAINS(LOWER(c.name), @q)
OR CONTAINS(LOWER(c.location), @q) OR CONTAINS(LOWER(c.bio), @q)
OR CONTAINS(LOWER(c.topLanguage), @q))
AND ${PUBLIC_FILTER}
ORDER BY c.score DESC`,
parameters: [{ name: '@q', value: searchTerm }],
}).fetchAll(),
]);

// RRF fusion, same k as /api/search.
const k = 60;
const rrf = new Map();
const allMap = new Map();
vectorRes.resources.forEach((r, i) => { rrf.set(r.login, (rrf.get(r.login) || 0) + 1 / (k + i + 1)); allMap.set(r.login, r); });
textRes.resources.forEach((r, i) => { rrf.set(r.login, (rrf.get(r.login) || 0) + 1 / (k + i + 1)); allMap.set(r.login, r); });
return [...rrf.keys()].map(login => allMap.get(login));
}

// text mode
const searchTerm = query.toLowerCase();
const { resources } = await container.items.query({
query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS}
FROM c
WHERE (CONTAINS(LOWER(c.login), @q) OR CONTAINS(LOWER(c.name), @q)
OR CONTAINS(LOWER(c.location), @q) OR CONTAINS(LOWER(c.bio), @q)
OR CONTAINS(LOWER(c.topLanguage), @q))
AND ${PUBLIC_FILTER}
ORDER BY c.score DESC`,
parameters: [{ name: '@q', value: searchTerm }],
}).fetchAll();
return resources;
}

/**
* Execute a saved search's criteria end-to-end: fetch candidates for the
* requested mode, then apply privacy + structured filters + text query
* locally (lib/saved-search.js) so filtering logic stays in one, unit-tested
* place regardless of where the candidate pool came from.
*/
export async function runSavedSearch(criteria) {
const candidates = await fetchCandidates({ query: criteria.query, mode: criteria.mode });
return runSavedSearchAgainstCandidates(candidates, criteria);
}
Loading