refactor: migrate draft/post API endpoints to use Drizzle ORM instead of raw SQL queries

This commit is contained in:
Ender 2025-10-24 15:05:17 +02:00
parent 038591c9cf
commit 93f93e4f96
6 changed files with 76 additions and 30 deletions

View File

@ -21,10 +21,10 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
if (savedId) {
(async () => {
try {
const res = await fetch(`/api/drafts/${savedId}`);
const res = await fetch(`/api/posts/${savedId}`);
if (res.ok) {
const data = await res.json();
setDraft(data.content || '');
setDraft(data.contentHtml || '');
setDraftId(data.id || savedId);
}
} catch {}
@ -32,10 +32,10 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
}
(async () => {
try {
const res = await fetch('/api/drafts');
const res = await fetch('/api/posts');
if (res.ok) {
const data = await res.json();
if (Array.isArray(data.items)) setDrafts(data.items);
if (Array.isArray(data.items)) setDrafts(data.items.map((p: any) => p.id));
}
} catch {}
})();
@ -45,10 +45,18 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
// Keep local fallback
localStorage.setItem('voxblog_draft', draft);
try {
const res = await fetch('/api/drafts', {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: draftId ?? undefined, content: draft }),
body: JSON.stringify({
id: draftId ?? undefined,
title: meta.title || undefined,
contentHtml: draft,
tags: meta.tagsText ? meta.tagsText.split(',').map(t => t.trim()).filter(Boolean) : undefined,
featureImage: meta.featureImage || undefined,
canonicalUrl: meta.canonicalUrl || undefined,
status: 'editing',
}),
});
if (res.ok) {
const data = await res.json();
@ -62,13 +70,13 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
const loadDraft = async (id: string) => {
try {
const res = await fetch(`/api/drafts/${id}`);
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) return;
const data = await res.json();
setDraft(data.content || '');
setDraft(data.contentHtml || '');
setDraftId(data.id || id);
localStorage.setItem('voxblog_draft_id', data.id || id);
if (data.content) localStorage.setItem('voxblog_draft', data.content);
if (data.contentHtml) localStorage.setItem('voxblog_draft', data.contentHtml);
} catch {}
};
@ -78,9 +86,12 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
Welcome to VoxBlog Editor
</Typography>
<Box sx={{ display: 'grid', gap: 3 }}>
<Recorder onTranscript={(t) => setDraft(t)} />
<Recorder
postId={draftId ?? undefined}
onInsertAtCursor={(html) => editorRef.current?.insertHtmlAtCursor(html)}
/>
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Drafts</Typography>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Posts</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', mb: 1 }}>
{drafts.map(id => (
<Button key={id} size="small" variant={draftId === id ? 'contained' : 'outlined'} onClick={() => loadDraft(id)}>

View File

@ -8,6 +8,7 @@ import { Button, Stack } from '@mui/material';
export type RichEditorHandle = {
insertImage: (src: string, alt?: string) => void;
insertHtmlAtCursor: (html: string) => void;
};
type Props = {
@ -34,6 +35,10 @@ const RichEditor = forwardRef<RichEditorHandle, Props>(({ value, onChange, place
if (!editor) return;
editor.chain().focus().setImage({ src, alt }).run();
},
insertHtmlAtCursor: (html: string) => {
if (!editor) return;
editor.chain().focus().insertContent(html).run();
},
}), [editor]);
useEffect(() => {

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
export default function Recorder({ onTranscript }: { onTranscript?: (t: string) => void }) {
export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { postId?: string; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void }) {
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const mimeRef = useRef<string>('audio/webm');
@ -87,7 +87,8 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string)
const form = new FormData();
const ext = c.mime.includes('mp4') ? 'm4a' : 'webm';
form.append('audio', c.blob, `recording.${ext}`);
const res = await fetch('/api/media/audio', { method: 'POST', body: form });
const url = postId ? `/api/media/audio?postId=${encodeURIComponent(postId)}` : '/api/media/audio';
const res = await fetch(url, { method: 'POST', body: form });
if (!res.ok) {
const txt = await res.text();
throw new Error(`Upload failed: ${res.status} ${txt}`);
@ -112,7 +113,7 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string)
const res = await fetch('/api/stt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket: c.uploadedBucket ?? undefined, key: c.uploadedKey }),
body: JSON.stringify({ bucket: c.uploadedBucket ?? undefined, key: c.uploadedKey, postId: postId ?? undefined, clipId: c.id }),
});
if (!res.ok) {
const txt = await res.text();
@ -148,8 +149,13 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string)
};
const applyTranscriptsToDraft = () => {
const text = clips.map(c => c.transcript || '').filter(Boolean).join('\n\n');
if (onTranscript) onTranscript(text);
const html = clips
.map(c => (c.transcript || '').trim())
.filter(Boolean)
.map(t => `<p>${t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>')}</p>`)
.join('');
if (onInsertAtCursor) onInsertAtCursor(html);
if (!onInsertAtCursor && onTranscript) onTranscript(html);
};
useEffect(() => {

View File

@ -2,7 +2,9 @@ import express from 'express';
import path from 'path';
import { promises as fs } from 'fs';
import crypto from 'crypto';
import { pool } from './db';
import { db } from './db';
import { posts } from './db/schema';
import { eq } from 'drizzle-orm';
const router = express.Router();
const draftsDir = path.resolve(__dirname, '../../../data/drafts');
@ -50,10 +52,23 @@ router.post('/', async (req, res) => {
try {
const now = new Date();
await pool.query(
'INSERT INTO posts (id, title, content_html, tags_text, feature_image, canonical_url, status, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE content_html = VALUES(content_html), updated_at = VALUES(updated_at)',
[draftId, null, content, null, null, null, 'editing', 1, now, now]
);
// Upsert: try insert, fall back to update
try {
await db.insert(posts).values({
id: draftId,
title: null as any,
contentHtml: content,
tagsText: null as any,
featureImage: null as any,
canonicalUrl: null as any,
status: 'editing' as any,
version: 1,
createdAt: now,
updatedAt: now,
});
} catch {
await db.update(posts).set({ contentHtml: content, updatedAt: now }).where(eq(posts.id, draftId));
}
} catch (e) {
// ignore DB errors to keep file-based save working
}

View File

@ -2,7 +2,9 @@ import express from 'express';
import multer from 'multer';
import crypto from 'crypto';
import { uploadBuffer, downloadObject, listObjects, deleteObject as s3DeleteObject } from './storage/s3';
import { pool } from './db';
import { db } from './db';
import { audioClips, posts } from './db/schema';
import { eq } from 'drizzle-orm';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
@ -126,14 +128,19 @@ router.post('/audio', upload.single('audio'), async (
contentType: mime,
});
// If postId provided, insert into audio_clips table
// If postId provided, insert into audio_clips table (Drizzle) and touch post.updated_at
if (postId) {
try {
const now = new Date();
await pool.query(
'INSERT INTO audio_clips (id, post_id, bucket, object_key, mime, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[clipId, postId, out.bucket, out.key, mime, now]
);
await db.insert(audioClips).values({
id: clipId,
postId,
bucket: out.bucket,
key: out.key,
mime,
createdAt: now,
});
await db.update(posts).set({ updatedAt: now }).where(eq(posts.id, postId));
} catch (e) {
console.error('[API] DB insert audio_clip failed:', e);
// continue anyway, response still returns S3 info

View File

@ -1,7 +1,9 @@
import express from 'express';
import { fetch, FormData } from 'undici';
import { downloadObject } from './storage/s3';
import { pool } from './db';
import { db } from './db';
import { audioClips, posts } from './db/schema';
import { eq } from 'drizzle-orm';
const router = express.Router();
@ -53,8 +55,8 @@ router.post('/', async (req, res) => {
// If postId and clipId provided, persist transcript into DB
if (postId && clipId) {
try {
await pool.query('UPDATE audio_clips SET transcript = ? WHERE id = ? AND post_id = ? LIMIT 1', [transcript, clipId, postId]);
await pool.query('UPDATE posts SET updated_at = ? WHERE id = ? LIMIT 1', [new Date(), postId]);
await db.update(audioClips).set({ transcript }).where(eq(audioClips.id, clipId));
await db.update(posts).set({ updatedAt: new Date() }).where(eq(posts.id, postId));
} catch (e) {
console.error('DB update transcript failed:', e);
}