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
95 changes: 87 additions & 8 deletions app/admin/dashboard/feed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, EyeOff, ExternalLink, Loader2, Search, Trash2 } from 'lucide-react';
import { CheckCircle, EyeOff, ExternalLink, Loader2, Search, ShieldCheck, Trash2 } from 'lucide-react';

interface FeedPost {
_id: string;
Expand All @@ -17,6 +17,8 @@ interface FeedPost {
postedAt?: string;
source: 'seed' | 'olostep-search-index' | 'community-submission' | 'admin-import';
status: 'approved' | 'pending' | 'hidden';
/** Set when the automatic check hid the post */
hiddenReason?: string;
createdAt: string;
}

Expand All @@ -40,13 +42,23 @@ const SEARCHES_PER_REQUEST = 40;
/** Topic searches in lib/linkedin-feed.ts; one search per member is added on top */
const TOPIC_SEARCH_COUNT = 40;

/** What a search run found, in words */
function foundSummary(added: number, rejected: number, needsReview: number) {
const parts = [`${added} new posts live`];
if (needsReview) parts.push(`${needsReview} waiting for review (LinkedIn didn't load them)`);
if (rejected) parts.push(`${rejected} skipped (no Dev Weekends mention or deleted)`);
return `${parts.join(', ')}.`;
}

export default function AdminFeedPage() {
const [posts, setPosts] = useState<FeedPost[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<FeedPost['status']>('pending');
const [processingId, setProcessingId] = useState<string | null>(null);
const [collecting, setCollecting] = useState(false);
const [collectMessage, setCollectMessage] = useState('');
const [checking, setChecking] = useState(false);
const [checkMessage, setCheckMessage] = useState('');
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
const [importMessage, setImportMessage] = useState('');
Expand Down Expand Up @@ -113,7 +125,8 @@ export default function AdminFeedPage() {
return;
}
setCollectMessage(
`Ran ${data.queries} of ${data.searchListSize} searches${data.failedQueries ? ` (${data.failedQueries} failed)` : ''}: ${data.added} new posts, ${data.enriched} updated.`
`Ran ${data.queries} of ${data.searchListSize} searches${data.failedQueries ? ` (${data.failedQueries} failed)` : ''}: ` +
foundSummary(data.added, data.rejected, data.needsReview)
);
await fetchPosts();
} catch {
Expand Down Expand Up @@ -148,7 +161,8 @@ export default function AdminFeedPage() {
let total = estimate;
let done = 0;
let added = 0;
let enriched = 0;
let rejected = 0;
let needsReview = 0;
let failed = 0;
try {
while (done < total) {
Expand All @@ -162,7 +176,8 @@ export default function AdminFeedPage() {
if (done === 0) total = data.searchListSize;
done += data.queries;
added += data.added;
enriched += data.enriched;
rejected += data.rejected;
needsReview += data.needsReview;
failed += data.failedQueries;
if (data.failedQueries === data.queries) {
setCollectMessage(`Stopped: every search in the last batch failed (check Olostep credits). ${added} new posts found.`);
Expand All @@ -171,7 +186,7 @@ export default function AdminFeedPage() {
setCollectMessage(`Searching… ${done} of ${total} done, ${added} new posts so far.`);
}
setCollectMessage(
`Searched everything: ${done} searches${failed ? ` (${failed} failed)` : ''}, ${added} new posts, ${enriched} updated.`
`Searched everything: ${done} searches${failed ? ` (${failed} failed)` : ''}. ${foundSummary(added, rejected, needsReview)}`
);
} catch {
setCollectMessage(`Stopped after ${done} searches with ${added} new posts.`);
Expand All @@ -181,6 +196,62 @@ export default function AdminFeedPage() {
}
};

/**
* Checks every live post from the starter list and search against its LinkedIn
* page, in batches, and hides the ones that don't mention Dev Weekends or were deleted.
*/
const checkLivePosts = async () => {
if (
!confirm(
'Check every live post found by search against its LinkedIn page, and hide the ones whose text ' +
"doesn't mention Dev Weekends? Hidden posts can be approved again. No Olostep credits are used."
)
) {
return;
}
setChecking(true);
setCheckMessage('');
let after: string | undefined;
let checked = 0;
let hidden = 0;
let unknown = 0;
try {
for (;;) {
const res = await fetch('/api/admin/feed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'recheck', after }),
});
const data = await res.json();
if (!res.ok) {
setCheckMessage(`${data.error ?? 'Check failed.'} Stopped after ${checked} posts, ${hidden} hidden.`);
return;
}
checked += data.checked;
hidden += data.hidden.length;
unknown += data.unknown;
if (data.checked > 0 && data.unknown === data.checked) {
setCheckMessage(`Stopped: LinkedIn didn't show any post in the last batch. Try again later. ${hidden} hidden so far.`);
return;
}
if (!data.lastId || data.remaining === 0) break;
after = data.lastId;
setCheckMessage(`Checking… ${checked} done, ${data.remaining} left, ${hidden} hidden so far.`);
}
setCheckMessage(
checked === 0
? 'Every live post has been checked already.'
: `Checked ${checked} posts: ${hidden} hidden (see the Hidden tab)` +
(unknown ? `, ${unknown} couldn't be loaded and stay live; run it again later to retry them.` : '.')
);
} catch {
setCheckMessage(`Stopped after ${checked} posts, ${hidden} hidden.`);
} finally {
setChecking(false);
await fetchPosts();
}
};

const importLinks = async () => {
setImporting(true);
setImportMessage('');
Expand Down Expand Up @@ -229,19 +300,24 @@ export default function AdminFeedPage() {
</div>
<div className="max-w-sm text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={collect} disabled={collecting}>
<Button variant="outline" onClick={checkLivePosts} disabled={checking || collecting}>
{checking ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
Check live posts
</Button>
<Button variant="outline" onClick={collect} disabled={collecting || checking}>
Find new posts
</Button>
<Button onClick={searchEverything} disabled={collecting}>
<Button onClick={searchEverything} disabled={collecting || checking}>
{collecting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search everything
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Find new posts runs 5 searches. Search everything runs the whole list once. Each search uses about{' '}
{CREDITS_PER_SEARCH} Olostep credits.
{CREDITS_PER_SEARCH} Olostep credits. A found post goes live only if its text on LinkedIn mentions Dev Weekends.
</p>
{collectMessage && <p className="mt-2 text-sm">{collectMessage}</p>}
{checkMessage && <p className="mt-2 text-sm">{checkMessage}</p>}
</div>
</div>

Expand Down Expand Up @@ -306,6 +382,9 @@ export default function AdminFeedPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{post.status === 'hidden' && post.hiddenReason && (
<p className="text-sm text-muted-foreground">Hidden automatically: {post.hiddenReason}.</p>
)}
{post.excerpt && <p className="text-sm">{post.excerpt}</p>}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
Expand Down
5 changes: 4 additions & 1 deletion app/api/admin/feed/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}

await connectDB();
const post = await LinkedInPost.findByIdAndUpdate(id, { status: data.status }, { new: true });
// An admin's approval stands: the automatic check won't hide the post again
const update =
data.status === 'approved' ? { status: data.status, checkedAt: new Date(), hiddenReason: '' } : { status: data.status };
const post = await LinkedInPost.findByIdAndUpdate(id, update, { new: true });
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
Expand Down
28 changes: 24 additions & 4 deletions app/api/admin/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
import { verify } from 'jsonwebtoken';
import connectDB from '@/lib/db';
import { collectFromOlostep, importPostUrls } from '@/lib/linkedin-feed';
import { collectFromOlostep, importPostUrls, recheckLivePosts } from '@/lib/linkedin-feed';
import { LinkedInPost } from '@/models/LinkedInPost';
import { ActivityLog } from '@/models/ActivityLog';

export const maxDuration = 300;

Expand Down Expand Up @@ -32,7 +33,7 @@ export async function GET() {

await connectDB();
const posts = await LinkedInPost.find()
.select('activityId url author title excerpt topic postedAt source status createdAt')
.select('activityId url author title excerpt topic postedAt source status hiddenReason createdAt')
.sort({ createdAt: -1 })
.lean();
return NextResponse.json(posts);
Expand All @@ -47,17 +48,36 @@ const MAX_SEARCHES_PER_REQUEST = 60;

/**
* Admin actions on the feed:
* - `{ action: "collect", searches?: number }` runs Olostep searches ("Find new posts", "Deep search")
* - `{ action: "collect", searches?: number }` runs Olostep searches ("Find new posts", "Search everything")
* - `{ action: "import", urls: string }` adds pasted LinkedIn post links, live straight away
* - `{ action: "recheck", after?: string }` checks the next batch of live posts against LinkedIn
* and hides the ones that don't mention Dev Weekends ("Check live posts")
*/
export async function POST(request: Request) {
try {
if (!(await getAdminUser())) {
const adminUsername = await getAdminUser();
if (!adminUsername) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body = await request.json().catch(() => ({}));

if (body.action === 'recheck') {
const after = typeof body.after === 'string' && /^[0-9a-f]{24}$/.test(body.after) ? body.after : undefined;
const result = await recheckLivePosts(after);
if (result.hidden.length > 0) {
await ActivityLog.create({
entityType: 'LinkedInPost',
entityId: 'feed-check',
action: 'hide',
adminUsername,
details: { hidden: result.hidden },
});
revalidatePath('/feed');
}
return NextResponse.json(result);
}

if (body.action === 'import') {
if (typeof body.urls !== 'string' || !body.urls.trim()) {
return NextResponse.json({ error: 'Paste at least one LinkedIn post link.' }, { status: 400 });
Expand Down
53 changes: 0 additions & 53 deletions data/linkedin-posts.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,24 +83,6 @@
"source": "olostep-search-index",
"submitted": false
},
{
"id": "7512945322910863360",
"url": "https://www.linkedin.com/posts/arslanchoudhary_github-arslan-choudharyredis-series-activity-7512945322910863360-cP_2",
"author": "Arslan Choudhary",
"handle": "arslanchoudhary",
"title": "Github · Arslan · Choudharyredis",
"excerpt": "",
"topic": "open-source",
"tags": [
"github",
"arslan",
"choudharyredis",
"series"
],
"postedAt": "2026-10-05T18:42:28+00:00",
"source": "olostep-search-index",
"submitted": false
},
{
"id": "7512911399573180416",
"url": "https://www.linkedin.com/posts/muhammad-danish-khattak_devweekends-dsa-datastructures-activity-7512911399573180416-ZczL",
Expand Down Expand Up @@ -580,23 +562,6 @@
"source": "olostep-search-index",
"submitted": false
},
{
"id": "7488018612931252224",
"url": "https://www.linkedin.com/posts/hussain-jamal-b5a76531a_looplearn-mern-backenddevelopment-activity-7488018612931252224-fQzw",
"author": "Hussain Jamal",
"handle": "hussain-jamal-b5a76531a",
"title": "Looplearn · Mern · Backenddevelopment",
"excerpt": "",
"topic": "engineering",
"tags": [
"looplearn",
"mern",
"backenddevelopment"
],
"postedAt": "2026-07-28T23:52:37+00:00",
"source": "olostep-search-index",
"submitted": false
},
{
"id": "7487348659353800704",
"url": "https://www.linkedin.com/posts/devweekends_devweekends-fellowship2026-dsa-activity-7487348659353800704-WMzr",
Expand Down Expand Up @@ -699,24 +664,6 @@
"source": "olostep-search-index",
"submitted": false
},
{
"id": "7480099269060993024",
"url": "https://www.linkedin.com/posts/haad-majeed_httpslnkdine4dqea-b-just-a-usual-day-activity-7480099269060993024-AQ33",
"author": "Haad Majeed",
"handle": "haad-majeed",
"title": "Httpslnkdine4Dqea · Just · Usual",
"excerpt": "",
"topic": "engineering",
"tags": [
"httpslnkdine4dqea",
"just",
"usual",
"day"
],
"postedAt": "2026-07-07T03:23:59+00:00",
"source": "olostep-search-index",
"submitted": false
},
{
"id": "7479818183089078272",
"url": "https://www.linkedin.com/posts/devweekends_data-structure-algorithm-dsa-linked-activity-7479818183089078272-r5Av",
Expand Down
Loading
Loading