fix: auto-publish approved blog submissions on approval
Approved user submissions now import into the blog automatically, with a backfill script for existing approvals and a WebSocket polyfill so backend Nostr relay queries work on Node 20. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Generated
+34
-1
@@ -20,7 +20,8 @@
|
|||||||
"nostr-tools": "^2.10.0",
|
"nostr-tools": "^2.10.0",
|
||||||
"slugify": "^1.6.8",
|
"slugify": "^1.6.8",
|
||||||
"ulid": "^3.0.2",
|
"ulid": "^3.0.2",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"@types/morgan": "^1.9.10",
|
"@types/morgan": "^1.9.10",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"prisma": "^6.0.0",
|
"prisma": "^6.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
@@ -807,6 +809,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -2553,6 +2565,27 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
|
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
|
||||||
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
|
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
|
||||||
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
|
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
|
||||||
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
|
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy",
|
||||||
|
"backfill-submissions": "dotenv -e ../.env -e .env -- tsx scripts/backfill-approved-submissions.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
"nostr-tools": "^2.10.0",
|
"nostr-tools": "^2.10.0",
|
||||||
"slugify": "^1.6.8",
|
"slugify": "^1.6.8",
|
||||||
"ulid": "^3.0.2",
|
"ulid": "^3.0.2",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@types/morgan": "^1.9.10",
|
"@types/morgan": "^1.9.10",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"prisma": "^6.0.0",
|
"prisma": "^6.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// One-time backfill: publish any APPROVED submissions that never got a blog
|
||||||
|
// Post (they predate auto-publish-on-approval). Idempotent — already-imported
|
||||||
|
// submissions are skipped, so it is safe to re-run.
|
||||||
|
//
|
||||||
|
// Usage (from backend/):
|
||||||
|
// npm run backfill-submissions
|
||||||
|
import { prisma } from '../src/db/prisma';
|
||||||
|
import { importPostFromNostr, resolveSubmissionImport } from '../src/services/postImport';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const approved = await prisma.submission.findMany({
|
||||||
|
where: { status: 'APPROVED' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${approved.length} APPROVED submission(s).`);
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const submission of approved) {
|
||||||
|
const label = `"${submission.title}" (${submission.id})`;
|
||||||
|
try {
|
||||||
|
const importInput = await resolveSubmissionImport(submission);
|
||||||
|
if (!importInput) {
|
||||||
|
console.warn(` FAILED ${label}: could not resolve Nostr event from relays.`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.post.findUnique({
|
||||||
|
where: { nostrEventId: importInput.nostrEventId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
console.log(` SKIP ${label}: already published (slug: ${existing.slug}).`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await importPostFromNostr(importInput);
|
||||||
|
console.log(` IMPORT ${label}: published (slug: ${post?.slug}).`);
|
||||||
|
imported++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` FAILED ${label}:`, err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Imported: ${imported}, Skipped: ${skipped}, Failed: ${failed}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
// The Nostr relay pool keeps websockets open, which would otherwise keep the
|
||||||
|
// process alive after the work is done.
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error('Backfill error:', err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+43
-57
@@ -1,10 +1,40 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
import { requireAuth, requires } from '../middleware/auth';
|
import { requireAuth, requires } from '../middleware/auth';
|
||||||
|
import { authService, type ResolvedAccess } from '../services/auth';
|
||||||
|
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
|
||||||
import { nostrService } from '../services/nostr';
|
import { nostrService } from '../services/nostr';
|
||||||
|
import { importPostFromNostr } from '../services/postImport';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Resolves access for an optional Bearer token (JWT or API key) without
|
||||||
|
// rejecting unauthenticated callers, so listing endpoints can stay public while
|
||||||
|
// still recognizing staff who pass `?all=true`.
|
||||||
|
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header || !header.startsWith('Bearer ')) return null;
|
||||||
|
const token = header.slice(7);
|
||||||
|
try {
|
||||||
|
if (looksLikeApiKey(token)) {
|
||||||
|
return await resolveApiKey(token);
|
||||||
|
}
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
|
||||||
|
return await authService.resolveAccess(payload.pubkey);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true when the requester is authenticated and may see hidden posts.
|
||||||
|
async function canViewHiddenPosts(req: Request): Promise<boolean> {
|
||||||
|
const access = await resolveOptionalAccess(req);
|
||||||
|
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
@@ -12,7 +42,10 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
const category = req.query.category as string | undefined;
|
const category = req.query.category as string | undefined;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: any = { visible: true };
|
const includeHidden = req.query.all === 'true' && (await canViewHiddenPosts(req));
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (!includeHidden) where.visible = true;
|
||||||
if (category) {
|
if (category) {
|
||||||
where.categories = {
|
where.categories = {
|
||||||
some: { category: { slug: category } },
|
some: { category: { slug: category } },
|
||||||
@@ -34,6 +67,7 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
posts,
|
posts,
|
||||||
|
total,
|
||||||
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -76,62 +110,14 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugBase = title
|
const result = await importPostFromNostr({
|
||||||
.toLowerCase()
|
nostrEventId,
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
naddr,
|
||||||
.replace(/^-|-$/g, '');
|
title,
|
||||||
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
|
excerpt,
|
||||||
|
authorPubkey,
|
||||||
const post = await prisma.post.upsert({
|
publishedAt,
|
||||||
where: { nostrEventId },
|
tags,
|
||||||
update: {
|
|
||||||
title,
|
|
||||||
excerpt: excerpt || undefined,
|
|
||||||
naddr: naddr || undefined,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
nostrEventId,
|
|
||||||
naddr: naddr || null,
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
excerpt: excerpt || null,
|
|
||||||
authorPubkey,
|
|
||||||
publishedAt: publishedAt
|
|
||||||
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
|
|
||||||
: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Array.isArray(tags) && tags.length > 0) {
|
|
||||||
const categoryIds: string[] = [];
|
|
||||||
for (const tag of tags as string[]) {
|
|
||||||
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
||||||
if (!catSlug) continue;
|
|
||||||
const category = await prisma.category.upsert({
|
|
||||||
where: { slug: catSlug },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: tag.charAt(0).toUpperCase() + tag.slice(1),
|
|
||||||
slug: catSlug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
categoryIds.push(category.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
|
|
||||||
if (categoryIds.length > 0) {
|
|
||||||
await prisma.postCategory.createMany({
|
|
||||||
data: categoryIds.map((categoryId) => ({
|
|
||||||
postId: post.id,
|
|
||||||
categoryId,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await prisma.post.findUnique({
|
|
||||||
where: { id: post.id },
|
|
||||||
include: { categories: { include: { category: true } } },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
import { requireAuth, requires } from '../middleware/auth';
|
import { requireAuth, requires } from '../middleware/auth';
|
||||||
|
import { importPostFromNostr, resolveSubmissionImport } from '../services/postImport';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -94,6 +95,22 @@ router.patch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approval publishes the referenced Nostr article to the blog. Do this
|
||||||
|
// before flipping the status so a failed import doesn't leave a submission
|
||||||
|
// marked APPROVED without a corresponding live post.
|
||||||
|
let post = null;
|
||||||
|
if (status === 'APPROVED') {
|
||||||
|
const importInput = await resolveSubmissionImport(submission);
|
||||||
|
if (!importInput) {
|
||||||
|
res.status(422).json({
|
||||||
|
error:
|
||||||
|
'Could not resolve the submitted Nostr event from relays. The post was not published, so the submission was left unchanged.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
post = await importPostFromNostr(importInput);
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.submission.update({
|
const updated = await prisma.submission.update({
|
||||||
where: { id: req.params.id as string },
|
where: { id: req.params.id as string },
|
||||||
data: {
|
data: {
|
||||||
@@ -103,7 +120,7 @@ router.patch(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(updated);
|
res.json({ submission: updated, post });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Review submission error:', err);
|
console.error('Review submission error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
import WebSocket from 'ws';
|
||||||
import { SimplePool, nip19 } from 'nostr-tools';
|
import { SimplePool, nip19 } from 'nostr-tools';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
// nostr-tools' SimplePool relies on a global WebSocket. Node only exposes one
|
||||||
|
// from v22 onward, so on older runtimes (this backend runs Node 20) every relay
|
||||||
|
// query silently fails without this polyfill. Set before any pool connects.
|
||||||
|
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') {
|
||||||
|
(globalThis as { WebSocket?: unknown }).WebSocket = WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
|
|
||||||
async function getRelayUrls(): Promise<string[]> {
|
async function getRelayUrls(): Promise<string[]> {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
import { nostrService } from './nostr';
|
||||||
|
|
||||||
|
export interface ImportPostInput {
|
||||||
|
nostrEventId: string;
|
||||||
|
naddr?: string | null;
|
||||||
|
title: string;
|
||||||
|
excerpt?: string | null;
|
||||||
|
authorPubkey: string;
|
||||||
|
publishedAt?: number | string | null;
|
||||||
|
tags?: string[];
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upserts a blog Post from a Nostr longform event. Shared by the manual import
|
||||||
|
// endpoint and the submission-approval flow so both produce identical results
|
||||||
|
// (auto slug, category links from `t` tags, visible by default).
|
||||||
|
export async function importPostFromNostr(input: ImportPostInput) {
|
||||||
|
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags, visible } = input;
|
||||||
|
|
||||||
|
const slugBase = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
|
||||||
|
|
||||||
|
const post = await prisma.post.upsert({
|
||||||
|
where: { nostrEventId },
|
||||||
|
update: {
|
||||||
|
title,
|
||||||
|
excerpt: excerpt || undefined,
|
||||||
|
naddr: naddr || undefined,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
nostrEventId,
|
||||||
|
naddr: naddr || null,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
excerpt: excerpt || null,
|
||||||
|
authorPubkey,
|
||||||
|
visible: visible ?? true,
|
||||||
|
publishedAt: publishedAt
|
||||||
|
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
|
||||||
|
: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(tags) && tags.length > 0) {
|
||||||
|
const categoryIds: string[] = [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
if (!catSlug) continue;
|
||||||
|
const category = await prisma.category.upsert({
|
||||||
|
where: { slug: catSlug },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: tag.charAt(0).toUpperCase() + tag.slice(1),
|
||||||
|
slug: catSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
categoryIds.push(category.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
|
||||||
|
if (categoryIds.length > 0) {
|
||||||
|
await prisma.postCategory.createMany({
|
||||||
|
data: categoryIds.map((categoryId) => ({
|
||||||
|
postId: post.id,
|
||||||
|
categoryId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.post.findUnique({
|
||||||
|
where: { id: post.id },
|
||||||
|
include: { categories: { include: { category: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionRef {
|
||||||
|
eventId: string | null;
|
||||||
|
naddr: string | null;
|
||||||
|
title: string;
|
||||||
|
authorPubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves the Nostr longform event referenced by a submission into a blog
|
||||||
|
// import payload. Returns null when the event cannot be resolved into something
|
||||||
|
// publishable (e.g. naddr that no relay can serve and no fallback event id).
|
||||||
|
export async function resolveSubmissionImport(
|
||||||
|
submission: SubmissionRef
|
||||||
|
): Promise<ImportPostInput | null> {
|
||||||
|
const event = submission.naddr
|
||||||
|
? await nostrService.fetchLongformEvent(submission.naddr)
|
||||||
|
: submission.eventId
|
||||||
|
? await nostrService.fetchEvent(submission.eventId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const nostrEventId: string | undefined = event?.id || submission.eventId || undefined;
|
||||||
|
if (!nostrEventId) return null;
|
||||||
|
|
||||||
|
const eventTags: string[][] = Array.isArray(event?.tags) ? (event!.tags as string[][]) : [];
|
||||||
|
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1];
|
||||||
|
const topicTags = eventTags
|
||||||
|
.filter((t) => t[0] === 't' && t[1])
|
||||||
|
.map((t) => (t[1] as string).toLowerCase());
|
||||||
|
|
||||||
|
const excerpt = ((event?.content as string) || '')
|
||||||
|
.slice(0, 200)
|
||||||
|
.replace(/[#*_\n]/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
nostrEventId,
|
||||||
|
naddr: submission.naddr || undefined,
|
||||||
|
title: submission.title || titleTag || 'Untitled',
|
||||||
|
excerpt: excerpt || undefined,
|
||||||
|
authorPubkey: event?.pubkey || submission.authorPubkey,
|
||||||
|
publishedAt: event?.created_at ?? null,
|
||||||
|
tags: topicTags.length > 0 ? topicTags : undefined,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() {
|
|||||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
||||||
const [reviewNote, setReviewNote] = useState("");
|
const [reviewNote, setReviewNote] = useState("");
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
const loadSubmissions = async () => {
|
const loadSubmissions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -61,10 +62,17 @@ export default function AdminSubmissionsPage() {
|
|||||||
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
|
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
try {
|
try {
|
||||||
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
|
const res = await api.reviewSubmission(id, {
|
||||||
|
status,
|
||||||
|
reviewNote: reviewNote.trim() || undefined,
|
||||||
|
});
|
||||||
setReviewingId(null);
|
setReviewingId(null);
|
||||||
setReviewNote("");
|
setReviewNote("");
|
||||||
|
if (status === "APPROVED" && res?.post) {
|
||||||
|
setSuccess(`Approved and published "${res.post.title}" to the blog.`);
|
||||||
|
}
|
||||||
await loadSubmissions();
|
await loadSubmissions();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -87,6 +95,7 @@ export default function AdminSubmissionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-error text-sm">{error}</p>}
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
|
{success && <p className="text-primary text-sm">{success}</p>}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user