Skip to content
Closed
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
101 changes: 101 additions & 0 deletions app/api/creator/analytics/route.ts
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 });
}
Comment on lines +7 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No authentication — any caller can query analytics for any wallet.

All four /api/creator/* routes validate the wallet format but never verify that the caller actually owns the wallet (no session/JWT/signature check). This means any user can fetch analytics, drafts, and NFTs for any wallet address. If this data is considered private, an authentication layer is needed.

This concern applies equally to app/api/creator/drafts/route.ts, app/api/creator/drafts/[draftKey]/route.ts, and app/api/creator/nfts/route.ts.

🤖 Prompt for AI Agents
In `@app/api/creator/analytics/route.ts` around lines 7 - 14, The GET handler
currently only validates wallet format (using VALIDATION_PATTERNS.walletAddress)
but does not authenticate/authorize the caller; update the handler to require
and validate an authentication token/session/signature (e.g., check a session
cookie, JWT in Authorization header, or verify a signed message) and ensure the
authenticated identity matches the requested wallet before returning analytics
(otherwise return 401/403 via NextResponse); apply the same pattern to the
handlers in app/api/creator/drafts/route.ts,
app/api/creator/drafts/[draftKey]/route.ts, and app/api/creator/nfts/route.ts so
each route (their GET functions) verifies auth and owner equality with the
wallet param.


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();
Comment on lines +18 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unbounded query: all stories for a wallet are fetched without limit.

If a prolific creator has thousands of stories, this query returns them all into memory, and the subsequent aggregation scans all their interactions. Consider adding a reasonable upper bound or pagination if the analytics response is meant to be consumed incrementally by the dashboard.

🤖 Prompt for AI Agents
In `@app/api/creator/analytics/route.ts` around lines 18 - 21, The query using
Story.find(...).select(...).sort(...).lean() fetches all stories for a wallet
into memory (variable stories) which can OOM for prolific creators; modify the
handler that calls Story.find to accept pagination parameters (e.g., page/limit
or cursor) and apply .limit(n).skip(page*limit) or a cursor-based filter to
Story.find, and update downstream aggregation logic that iterates over stories
to operate on a single page (or to stream/process incrementally) so analytics
run only on the bounded result set; ensure default limit is set (e.g., 50–100)
and expose pagination params in the API surface used by the dashboard.


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 });
}
}

73 changes: 73 additions & 0 deletions app/api/creator/drafts/[draftKey]/route.ts
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();

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 });
}
}

92 changes: 92 additions & 0 deletions app/api/creator/drafts/route.ts
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 });
}

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),
},
});
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

86 changes: 86 additions & 0 deletions app/api/creator/nfts/route.ts
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unbounded Promise.all for on-chain status checks can overwhelm the RPC provider.

Every pending NFT triggers a concurrent checkTxStatus RPC call. For a creator with many pending NFTs, this could hit rate limits or cause timeouts. Consider batching or limiting concurrency (e.g., processing in chunks of 5–10), or caching confirmed statuses so they aren't re-checked.

Also, the empty catch block on lines 58–59 silently swallows RPC errors, making it hard to diagnose blockchain connectivity issues. Consider logging at warn level.

Sketch: limit concurrency and add logging
+    // Process in batches to avoid RPC rate limiting
+    const BATCH_SIZE = 5;
+    for (let i = 0; i < candidates.length; i += BATCH_SIZE) {
+      const batch = candidates.slice(i, i + BATCH_SIZE);
+      await Promise.all(batch.map(async (story: any) => { /* ... */ }));
+    }

         } catch {
+          console.warn(`[NFTs] Failed to check tx status for ${txHash}`);
         }
🤖 Prompt for AI Agents
In `@app/api/creator/nfts/route.ts` around lines 36 - 60, The current
implementation maps over candidates and calls checkTxStatus for every pending tx
concurrently (inside payload creation), which can overwhelm RPC providers and
also silently swallows errors; modify the flow so pending tx checks are
performed with bounded concurrency (e.g., process candidates in chunks of ~5–10
or use a concurrency limiter when invoking checkTxStatus) and avoid awaiting all
RPC calls at once for payload generation, cache or persist confirmed
tokenId/status to skip re-checks, and replace the empty catch with a
processLogger.warn (or similar) that logs the txHash and the caught error so
RPC/connectivity issues are visible; key symbols to update: candidates mapping
that builds payload, the checkTxStatus calls, txHash, tokenId, and syncStatus
handling.


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 });
}
}

Loading
Loading