178 lines
5.4 KiB
TypeScript
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;
|