voxblog/apps/api/src/posts.ts

178 lines
5.4 KiB
TypeScript

import express from 'express';
import crypto from 'crypto';
import { db } from './db';
import { posts, audioClips } from './db/schema';
import { desc, asc, eq, and, or, like, sql } from 'drizzle-orm';
const router = express.Router();
// List posts (minimal info)
router.get('/', async (req, res) => {
try {
const page = Math.max(parseInt((req.query.page as string) || '1', 10), 1);
const pageSizeRaw = Math.max(parseInt((req.query.pageSize as string) || '20', 10), 1);
const pageSize = Math.min(pageSizeRaw, 100);
const q = ((req.query.q as string) || '').trim();
const sort = ((req.query.sort as string) || 'updatedAt:desc').toLowerCase();
const [sortField, sortDir] = sort.split(':');
const whereConds = [] as any[];
if (q) {
const likeQ = `%${q}%`;
whereConds.push(or(like(posts.title, likeQ), like(posts.contentHtml, likeQ)));
}
const whereExpr = whereConds.length > 0 ? and(...whereConds) : undefined;
// total count
const countRows = await db
.select({ cnt: sql<number>`COUNT(*)` })
.from(posts)
.where(whereExpr as any);
const total = (countRows?.[0]?.cnt as unknown as number) || 0;
// sorting
let orderByExpr: any = desc(posts.updatedAt);
const dir = sortDir === 'asc' ? 'asc' : 'desc';
if (sortField === 'title') orderByExpr = dir === 'asc' ? asc(posts.title) : desc(posts.title);
else if (sortField === 'status') orderByExpr = dir === 'asc' ? asc(posts.status) : desc(posts.status);
else orderByExpr = dir === 'asc' ? asc(posts.updatedAt) : desc(posts.updatedAt);
const offset = (page - 1) * pageSize;
const rows = await db
.select({ id: posts.id, title: posts.title, status: posts.status, updatedAt: posts.updatedAt })
.from(posts)
.where(whereExpr as any)
.orderBy(orderByExpr)
.limit(pageSize)
.offset(offset);
return res.json({ items: rows, total, page, pageSize });
} catch (err) {
console.error('List posts error:', err);
return res.status(500).json({ error: 'Failed to list posts' });
}
});
// Get full post with audio clips
router.get('/:id', async (req, res) => {
try {
const id = req.params.id;
const rows = await db
.select({
id: posts.id,
title: posts.title,
contentHtml: posts.contentHtml,
tagsText: posts.tagsText,
featureImage: posts.featureImage,
canonicalUrl: posts.canonicalUrl,
status: posts.status,
createdAt: posts.createdAt,
updatedAt: posts.updatedAt,
version: posts.version,
})
.from(posts)
.where(eq(posts.id, id))
.limit(1);
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Post not found' });
const post = rows[0];
const clips = await db
.select({
id: audioClips.id,
postId: audioClips.postId,
bucket: audioClips.bucket,
key: audioClips.key,
mime: audioClips.mime,
transcript: audioClips.transcript,
durationMs: audioClips.durationMs,
createdAt: audioClips.createdAt,
})
.from(audioClips)
.where(eq(audioClips.postId, id))
.orderBy(audioClips.createdAt);
return res.json({ ...post, audioClips: clips });
} catch (err) {
console.error('Get post error:', err);
return res.status(500).json({ error: 'Failed to get post' });
}
});
// Create/update post (no audio clips here)
router.post('/', async (req, res) => {
try {
const {
id,
title,
contentHtml,
tags,
featureImage,
canonicalUrl,
status,
} = req.body as {
id?: string;
title?: string;
contentHtml?: string;
tags?: string[] | string;
featureImage?: string;
canonicalUrl?: string;
status?: string;
};
const tagsText = Array.isArray(tags) ? tags.join(',') : (typeof tags === 'string' ? tags : null);
if (!contentHtml) return res.status(400).json({ error: 'contentHtml is required' });
const now = new Date();
if (!id) {
const newId = crypto.randomUUID();
await db.insert(posts).values({
id: newId,
title: title ?? null as any,
contentHtml,
tagsText: tagsText ?? null as any,
featureImage: featureImage ?? null as any,
canonicalUrl: canonicalUrl ?? null as any,
status: (status as any) ?? 'editing',
version: 1,
createdAt: now,
updatedAt: now,
});
return res.json({ id: newId });
} else {
await db.update(posts).set({
title: title ?? null as any,
contentHtml,
tagsText: tagsText ?? null as any,
featureImage: featureImage ?? null as any,
canonicalUrl: canonicalUrl ?? null as any,
status: (status as any) ?? 'editing',
updatedAt: now,
}).where(eq(posts.id, id));
return res.json({ id });
}
} catch (err) {
console.error('Save post error:', err);
return res.status(500).json({ error: 'Failed to save post' });
}
});
// Delete post (and its audio clips)
router.delete('/:id', async (req, res) => {
try {
const id = req.params.id;
if (!id) return res.status(400).json({ error: 'id is required' });
await db.delete(audioClips).where(eq(audioClips.postId, id));
await db.delete(posts).where(eq(posts.id, id));
return res.json({ success: true });
} catch (err) {
console.error('Delete post error:', err);
return res.status(500).json({ error: 'Failed to delete post' });
}
});
export default router;