-
Notifications
You must be signed in to change notification settings - Fork 0
perf: creator Dashboard 2.0 – Unified Library, Analytics & Draft Management #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import dbConnect from '@/lib/dbConnect'; | ||
| import Story from '../../../../models/Story'; | ||
| import { UserInteraction } from '../../../../models/UserInteraction'; | ||
| import { VALIDATION_PATTERNS } from '@/lib/constants'; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| try { | ||
| const { searchParams } = new URL(request.url); | ||
| const wallet = (searchParams.get('wallet') || '').trim().toLowerCase(); | ||
|
|
||
| if (!wallet || !VALIDATION_PATTERNS.walletAddress.test(wallet)) { | ||
| return NextResponse.json({ error: 'Invalid wallet address' }, { status: 400 }); | ||
| } | ||
|
|
||
| await dbConnect(); | ||
|
|
||
| const stories = await Story.find({ authorWallet: wallet }) | ||
| .select({ _id: 1, title: 1, status: 1, updatedAt: 1, nftTokenId: 1, nftTxHash: 1 }) | ||
| .sort({ updatedAt: -1 }) | ||
| .lean(); | ||
|
|
||
| if (stories.length === 0) { | ||
| return NextResponse.json({ | ||
| totals: { views: 0, likes: 0, bookmarks: 0, mints: 0 }, | ||
| stories: [], | ||
| }); | ||
| } | ||
|
|
||
| const storyIds = stories.map((story: any) => story._id); | ||
|
|
||
| const aggregation = await UserInteraction.aggregate([ | ||
| { | ||
| $match: { | ||
| storyId: { $in: storyIds }, | ||
| type: { $in: ['VIEW', 'LIKE', 'BOOKMARK'] }, | ||
| }, | ||
| }, | ||
| { | ||
| $group: { | ||
| _id: { storyId: '$storyId', type: '$type' }, | ||
| value: { $sum: '$value' }, | ||
| }, | ||
| }, | ||
| ]); | ||
|
|
||
| const metricsByStoryId = new Map< | ||
| string, | ||
| { views: number; likes: number; bookmarks: number } | ||
| >(); | ||
|
|
||
| for (const row of aggregation) { | ||
| const storyId = row?._id?.storyId?.toString?.() || ''; | ||
| const type = row?._id?.type as 'VIEW' | 'LIKE' | 'BOOKMARK'; | ||
| const value = Number(row?.value) || 0; | ||
| if (!storyId || !type) { | ||
| continue; | ||
| } | ||
| const existing = metricsByStoryId.get(storyId) || { views: 0, likes: 0, bookmarks: 0 }; | ||
| if (type === 'VIEW') existing.views += value; | ||
| if (type === 'LIKE') existing.likes += value; | ||
| if (type === 'BOOKMARK') existing.bookmarks += value; | ||
| metricsByStoryId.set(storyId, existing); | ||
| } | ||
|
|
||
| const storyRows = stories.map((story: any) => { | ||
| const storyId = story._id.toString(); | ||
| const engagement = metricsByStoryId.get(storyId) || { views: 0, likes: 0, bookmarks: 0 }; | ||
| const minted = story.nftTokenId ? 1 : 0; | ||
| return { | ||
| storyId, | ||
| title: story.title || 'Untitled', | ||
| status: story.status || 'draft', | ||
| updatedAt: story.updatedAt ? new Date(story.updatedAt).getTime() : Date.now(), | ||
| views: engagement.views, | ||
| likes: engagement.likes, | ||
| bookmarks: engagement.bookmarks, | ||
| mints: minted, | ||
| }; | ||
| }); | ||
|
|
||
| const totals = storyRows.reduce( | ||
| (acc, row) => { | ||
| acc.views += row.views; | ||
| acc.likes += row.likes; | ||
| acc.bookmarks += row.bookmarks; | ||
| acc.mints += row.mints; | ||
| return acc; | ||
| }, | ||
| { views: 0, likes: 0, bookmarks: 0, mints: 0 } | ||
| ); | ||
|
|
||
| return NextResponse.json({ | ||
| totals, | ||
| stories: storyRows, | ||
| }); | ||
| } catch (error) { | ||
| return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import dbConnect from '@/lib/dbConnect'; | ||
| import Draft from '../../../../../models/Draft'; | ||
| import { VALIDATION_PATTERNS } from '@/lib/constants'; | ||
|
|
||
| export async function GET( | ||
| request: NextRequest, | ||
| { params }: { params: { draftKey: string } } | ||
| ) { | ||
| try { | ||
| const { searchParams } = new URL(request.url); | ||
| const wallet = (searchParams.get('wallet') || '').trim().toLowerCase(); | ||
| const draftKey = (params.draftKey || '').trim(); | ||
|
|
||
| if (!wallet || !VALIDATION_PATTERNS.walletAddress.test(wallet)) { | ||
| return NextResponse.json({ error: 'Invalid wallet address' }, { status: 400 }); | ||
| } | ||
| if (!draftKey) { | ||
| return NextResponse.json({ error: 'draftKey is required' }, { status: 400 }); | ||
| } | ||
|
|
||
| await dbConnect(); | ||
|
|
||
| const draft = await Draft.findOne({ draftKey, ownerWallet: wallet }).lean(); | ||
| if (!draft) { | ||
| return NextResponse.json({ error: 'Draft not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| const currentUpdatedAt = draft.current?.updatedAt | ||
| ? new Date(draft.current.updatedAt).getTime() | ||
| : Date.now(); | ||
|
bibhupradhanofficial marked this conversation as resolved.
|
||
|
|
||
| return NextResponse.json({ | ||
| draftKey: draft.draftKey, | ||
| storyType: draft.storyType, | ||
| storyFormat: draft.storyFormat, | ||
| ownerWallet: draft.ownerWallet, | ||
| ownerRole: draft.ownerRole, | ||
| current: { | ||
| title: draft.current?.title || '', | ||
| description: draft.current?.description || '', | ||
| genre: draft.current?.genre || '', | ||
| content: draft.current?.content || '', | ||
| coverImageName: draft.current?.coverImageName || '', | ||
| updatedAt: currentUpdatedAt, | ||
| version: draft.current?.version || 1, | ||
| }, | ||
| versions: (draft.versions || []).map((version: any) => ({ | ||
| id: version._id?.toString?.() || '', | ||
| title: version.title || '', | ||
| description: version.description || '', | ||
| genre: version.genre || '', | ||
| content: version.content || '', | ||
| coverImageName: version.coverImageName || '', | ||
| updatedAt: version.updatedAt ? new Date(version.updatedAt).getTime() : Date.now(), | ||
| version: version.version || 1, | ||
| reason: version.reason || 'autosave', | ||
| })), | ||
| aiMetadata: { | ||
| pipelineState: draft.aiMetadata?.pipelineState || 'idle', | ||
| suggestedEdits: draft.aiMetadata?.suggestedEdits || [], | ||
| lastEditedByAIAt: draft.aiMetadata?.lastEditedByAIAt | ||
| ? new Date(draft.aiMetadata.lastEditedByAIAt).getTime() | ||
| : null, | ||
| }, | ||
| createdAt: draft.createdAt ? new Date(draft.createdAt).getTime() : null, | ||
| updatedAt: draft.updatedAt ? new Date(draft.updatedAt).getTime() : null, | ||
| }); | ||
| } catch (error) { | ||
| return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import dbConnect from '@/lib/dbConnect'; | ||
| import Draft from '../../../../models/Draft'; | ||
| import { VALIDATION_PATTERNS } from '@/lib/constants'; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| try { | ||
| const { searchParams } = new URL(request.url); | ||
| const wallet = (searchParams.get('wallet') || '').trim().toLowerCase(); | ||
| const query = (searchParams.get('q') || '').trim(); | ||
| const pipelineState = (searchParams.get('pipeline') || '').trim(); | ||
| const page = Math.max(1, Number.parseInt(searchParams.get('page') || '1', 10) || 1); | ||
| const limit = Math.max( | ||
| 1, | ||
| Math.min(50, Number.parseInt(searchParams.get('limit') || '20', 10) || 20) | ||
| ); | ||
|
|
||
| if (!wallet || !VALIDATION_PATTERNS.walletAddress.test(wallet)) { | ||
| return NextResponse.json({ error: 'Invalid wallet address' }, { status: 400 }); | ||
| } | ||
|
Comment on lines
+18
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== Checking for middleware files ==="
fd 'middleware.ts' --type f
echo
echo "=== Listing creator API route files ==="
fd 'route.ts' 'app/api/creator' --type f
echo
echo "=== Searching for auth/session verification in creator APIs ==="
rg -n --type=ts -C3 'getServerSession|auth\(|verify|jwt|clerk|siwe|signature|session' 'app/api/creator' 2>/dev/null || echo "No auth patterns found"
echo
echo "=== Checking wallet-param based access in draft routes ==="
rg -n --type=ts -C3 'searchParams\.get.*wallet|ownerWallet|walletAddress' 'app/api/creator/drafts' 2>/dev/null || echo "Pattern not found, checking entire file"
echo
echo "=== Full content of drafts/route.ts to understand auth context ==="
wc -l app/api/creator/drafts/route.ts && head -50 app/api/creator/drafts/route.tsRepository: IndieHub25/GroqTales Length of output: 6903 Implement authentication and caller-ownership verification for draft access. The GET endpoints at Instead of filtering by client-supplied wallet, these endpoints must:
🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bibhupradhanofficial implement authentication with already set up supabase setup
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| await dbConnect(); | ||
|
|
||
| const filter: Record<string, any> = { ownerWallet: wallet }; | ||
|
|
||
| if (pipelineState && ['idle', 'ready', 'processing'].includes(pipelineState)) { | ||
| filter['aiMetadata.pipelineState'] = pipelineState; | ||
| } | ||
|
|
||
| if (query) { | ||
| const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| filter.$or = [ | ||
| { draftKey: { $regex: escaped, $options: 'i' } }, | ||
| { 'current.title': { $regex: escaped, $options: 'i' } }, | ||
| { 'current.genre': { $regex: escaped, $options: 'i' } }, | ||
| { storyType: { $regex: escaped, $options: 'i' } }, | ||
| { storyFormat: { $regex: escaped, $options: 'i' } }, | ||
| ]; | ||
| } | ||
|
|
||
| const total = await Draft.countDocuments(filter); | ||
| const items = await Draft.find(filter) | ||
| .sort({ updatedAt: -1 }) | ||
| .skip((page - 1) * limit) | ||
| .limit(limit) | ||
| .select({ | ||
| draftKey: 1, | ||
| storyType: 1, | ||
| storyFormat: 1, | ||
| ownerWallet: 1, | ||
| ownerRole: 1, | ||
| current: { title: 1, genre: 1, updatedAt: 1, version: 1 }, | ||
| aiMetadata: { pipelineState: 1, lastEditedByAIAt: 1 }, | ||
| createdAt: 1, | ||
| updatedAt: 1, | ||
| }) | ||
| .lean(); | ||
|
|
||
| return NextResponse.json({ | ||
| items: items.map((item: any) => ({ | ||
| draftKey: item.draftKey, | ||
| storyType: item.storyType, | ||
| storyFormat: item.storyFormat, | ||
| ownerWallet: item.ownerWallet, | ||
| ownerRole: item.ownerRole, | ||
| current: { | ||
| title: item.current?.title || '', | ||
| genre: item.current?.genre || '', | ||
| updatedAt: item.current?.updatedAt ? new Date(item.current.updatedAt).getTime() : null, | ||
| version: item.current?.version || 1, | ||
| }, | ||
| aiMetadata: { | ||
| pipelineState: item.aiMetadata?.pipelineState || 'idle', | ||
| lastEditedByAIAt: item.aiMetadata?.lastEditedByAIAt | ||
| ? new Date(item.aiMetadata.lastEditedByAIAt).getTime() | ||
| : null, | ||
| }, | ||
| createdAt: item.createdAt ? new Date(item.createdAt).getTime() : null, | ||
| updatedAt: item.updatedAt ? new Date(item.updatedAt).getTime() : null, | ||
| })), | ||
| pagination: { | ||
| page, | ||
| limit, | ||
| total, | ||
| pages: Math.ceil(total / limit), | ||
| }, | ||
|
Drago-03 marked this conversation as resolved.
|
||
| }); | ||
| } catch (error) { | ||
| return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import dbConnect from '@/lib/dbConnect'; | ||
| import Story from '../../../../models/Story'; | ||
| import { BLOCKCHAIN_CONFIG, VALIDATION_PATTERNS } from '@/lib/constants'; | ||
| import { checkTxStatus } from '@/lib/blockchain'; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| try { | ||
| const { searchParams } = new URL(request.url); | ||
| const wallet = (searchParams.get('wallet') || '').trim().toLowerCase(); | ||
|
|
||
| if (!wallet || !VALIDATION_PATTERNS.walletAddress.test(wallet)) { | ||
| return NextResponse.json({ error: 'Invalid wallet address' }, { status: 400 }); | ||
| } | ||
|
|
||
| await dbConnect(); | ||
|
|
||
| const stories = await Story.find({ authorWallet: wallet }) | ||
| .select({ | ||
| _id: 1, | ||
| title: 1, | ||
| status: 1, | ||
| ipfsHash: 1, | ||
| nftTxHash: 1, | ||
| nftTokenId: 1, | ||
| updatedAt: 1, | ||
| }) | ||
| .sort({ updatedAt: -1 }) | ||
| .lean(); | ||
|
|
||
| const explorerBase = BLOCKCHAIN_CONFIG.networks.monad.explorerUrl; | ||
| const contract = BLOCKCHAIN_CONFIG.contracts.storyNFT || ''; | ||
|
|
||
| const candidates = stories.filter((story: any) => story.status !== 'draft'); | ||
|
|
||
| const payload = await Promise.all(candidates.map(async (story: any) => { | ||
| const txHash = story.nftTxHash || undefined; | ||
| let tokenId = story.nftTokenId || undefined; | ||
| const status = story.status || 'draft'; | ||
| let syncStatus: 'missing' | 'pending' | 'confirmed' | 'failed' = | ||
| status === 'failed' | ||
| ? 'failed' | ||
| : tokenId | ||
| ? 'confirmed' | ||
| : txHash || status === 'publishing' | ||
| ? 'pending' | ||
| : 'missing'; | ||
|
|
||
| if (txHash && syncStatus === 'pending') { | ||
| try { | ||
| const chainStatus = await checkTxStatus(txHash); | ||
| if (chainStatus.status === 'confirmed') { | ||
| syncStatus = 'confirmed'; | ||
| tokenId = tokenId || (chainStatus as any).tokenId || undefined; | ||
| } else if (chainStatus.status === 'reverted') { | ||
| syncStatus = 'failed'; | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
|
Comment on lines
+36
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard on-chain checks with bounded execution and timeout. This endpoint can fire one blockchain status request per story in parallel, with no timeout. Under larger wallets, this can overload RPC and tie up request handling. Also, silent catch hides operational failures. 🛡️ Safer pattern (timeout + controlled execution)- const payload = await Promise.all(candidates.map(async (story: any) => {
+ const withTimeout = <T,>(p: Promise<T>, ms: number) =>
+ Promise.race([
+ p,
+ new Promise<never>((_, reject) =>
+ setTimeout(() => reject(new Error('tx-status-timeout')), ms)
+ ),
+ ]);
+
+ const payload = [];
+ for (const story of candidates) {
@@
- if (txHash && syncStatus === 'pending') {
+ if (txHash && syncStatus === 'pending') {
try {
- const chainStatus = await checkTxStatus(txHash);
+ const chainStatus = await withTimeout(checkTxStatus(txHash), 5000);
if (chainStatus.status === 'confirmed') {
syncStatus = 'confirmed';
tokenId = tokenId || (chainStatus as any).tokenId || undefined;
} else if (chainStatus.status === 'reverted') {
syncStatus = 'failed';
}
- } catch {
+ } catch (error) {
+ console.warn(`[creator/nfts] tx status check failed for ${txHash}:`, error);
}
}
@@
- }));
+ payload.push({
+ storyId: story._id.toString(),
+ title: story.title || 'Untitled',
+ status,
+ ipfsHash: story.ipfsHash || undefined,
+ nftTxHash: txHash,
+ nftTokenId: tokenId,
+ syncStatus,
+ explorer,
+ updatedAt: story.updatedAt ? new Date(story.updatedAt).getTime() : Date.now(),
+ });
+ }🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bibhupradhanofficial fix this
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| const explorer = { | ||
| tx: txHash ? `${explorerBase}/tx/${txHash}` : undefined, | ||
| token: contract && tokenId ? `${explorerBase}/token/${contract}?a=${tokenId}` : undefined, | ||
| contract: contract ? `${explorerBase}/address/${contract}` : undefined, | ||
| }; | ||
|
|
||
| return { | ||
| storyId: story._id.toString(), | ||
| title: story.title || 'Untitled', | ||
| status, | ||
| ipfsHash: story.ipfsHash || undefined, | ||
| nftTxHash: txHash, | ||
| nftTokenId: tokenId, | ||
| syncStatus, | ||
| explorer, | ||
| updatedAt: story.updatedAt ? new Date(story.updatedAt).getTime() : Date.now(), | ||
| }; | ||
| })); | ||
|
|
||
| return NextResponse.json(payload); | ||
| } catch (error) { | ||
| return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bound this route to avoid unbounded query and aggregation load.
The current flow fetches all stories and aggregates over all IDs in one request. For large wallets, this can degrade latency and memory usage significantly. Add an enforced limit (and ideally pagination/cursor support).
💡 Proposed hard-cap fix
export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const wallet = (searchParams.get('wallet') || '').trim().toLowerCase(); + const limit = Math.min( + Math.max(Number(searchParams.get('limit') || 200), 1), + 500 + ); @@ const stories = await Story.find({ authorWallet: wallet }) .select({ _id: 1, title: 1, status: 1, updatedAt: 1, nftTokenId: 1, nftTxHash: 1 }) .sort({ updatedAt: -1 }) + .limit(limit) .lean();Also applies to: 30-45
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bibhupradhanofficial Bound the route
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.