feat: add MySQL database integration with Drizzle ORM for post and audio clip storage

This commit is contained in:
Ender 2025-10-24 14:57:54 +02:00
parent 3f2d3f0e8f
commit 038591c9cf
9 changed files with 245 additions and 7 deletions

View File

@ -45,6 +45,7 @@
"debug": "^4.4.0",
"depd": "^2.0.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
@ -56,6 +57,7 @@
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"multer": "^2.0.2",
"mysql2": "^3.15.3",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",

29
apps/api/src/db.ts Normal file
View File

@ -0,0 +1,29 @@
import mysql from 'mysql2/promise';
import { drizzle } from 'drizzle-orm/mysql2';
import * as schema from './db/schema';
const host = process.env.DB_HOST || 'localhost';
const port = Number(process.env.DB_PORT || 3306);
const user = process.env.DB_USER || '';
const password = process.env.DB_PASSWORD || '';
const database = process.env.DB_NAME || '';
export const pool = mysql.createPool({
host,
port,
user,
password,
database,
waitForConnections: true,
connectionLimit: 10,
maxIdle: 10,
idleTimeout: 60000,
queueLimit: 0,
});
export async function query<T = any>(sql: string, params: any[] = []): Promise<[T[], any]> {
const [rows, fields] = await pool.query(sql, params);
return [rows as T[], fields];
}
export const db = drizzle(pool, { schema, mode: 'default' });

35
apps/api/src/db/schema.ts Normal file
View File

@ -0,0 +1,35 @@
import { mysqlTable, varchar, text, mysqlEnum, int, datetime } from 'drizzle-orm/mysql-core';
export const postStatusEnum = mysqlEnum('status', [
'inbox',
'editing',
'ready_for_publish',
'published',
'archived',
]);
export const posts = mysqlTable('posts', {
id: varchar('id', { length: 36 }).primaryKey(),
title: varchar('title', { length: 255 }),
contentHtml: text('content_html').notNull(),
tagsText: text('tags_text'),
featureImage: text('feature_image'),
canonicalUrl: text('canonical_url'),
status: postStatusEnum.notNull().default('editing'),
ghostPostId: varchar('ghost_post_id', { length: 64 }),
ghostUrl: text('ghost_url'),
version: int('version').notNull().default(1),
createdAt: datetime('created_at', { fsp: 3 }).notNull(),
updatedAt: datetime('updated_at', { fsp: 3 }).notNull(),
});
export const audioClips = mysqlTable('audio_clips', {
id: varchar('id', { length: 36 }).primaryKey(),
postId: varchar('post_id', { length: 36 }).notNull(),
bucket: varchar('bucket', { length: 128 }).notNull(),
key: text('object_key').notNull(),
mime: varchar('mime', { length: 64 }).notNull(),
transcript: text('transcript'),
durationMs: int('duration_ms'),
createdAt: datetime('created_at', { fsp: 3 }).notNull(),
});

View File

@ -2,6 +2,7 @@ import express from 'express';
import path from 'path';
import { promises as fs } from 'fs';
import crypto from 'crypto';
import { pool } from './db';
const router = express.Router();
const draftsDir = path.resolve(__dirname, '../../../data/drafts');
@ -46,6 +47,16 @@ router.post('/', async (req, res) => {
const file = path.join(draftsDir, `${draftId}.json`);
const payload = { id: draftId, content, updatedAt: new Date().toISOString() };
await fs.writeFile(file, JSON.stringify(payload, null, 2), 'utf8');
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]
);
} catch (e) {
// ignore DB errors to keep file-based save working
}
res.json({ success: true, id: draftId });
} catch (err) {
console.error('Save draft error:', err);

View File

@ -8,6 +8,7 @@ import authRouter from './auth';
import mediaRouter from './media';
import sttRouter from './stt';
import draftsRouter from './drafts';
import postsRouter from './posts';
import ghostRouter from './ghost';
const app = express();
@ -26,6 +27,7 @@ app.use('/api/auth', authRouter);
app.use('/api/media', mediaRouter);
app.use('/api/stt', sttRouter);
app.use('/api/drafts', draftsRouter);
app.use('/api/posts', postsRouter);
app.use('/api/ghost', ghostRouter);
app.get('/api/health', (_req, res) => {
res.json({ ok: true });

View File

@ -2,6 +2,7 @@ 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';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
@ -110,9 +111,13 @@ router.post('/audio', upload.single('audio'), async (
const bucket = process.env.S3_BUCKET || 'voxblog';
const mime = req.file.mimetype || 'application/octet-stream';
const ext = mime === 'audio/webm' ? 'webm' : mime.split('/')[1] || 'bin';
const key = `audio/${new Date().toISOString().slice(0,10)}/${crypto.randomUUID()}.${ext}`;
console.log('[API] Uploading file', { mime, size: req.file.size, bucket, key });
const ext = mime === 'audio/webm' ? 'webm' : (mime === 'audio/mp4' ? 'm4a' : (mime.split('/')[1] || 'bin'));
const postId = (req.query.postId as string) || '';
const clipId = crypto.randomUUID();
const key = postId
? `audio/posts/${encodeURIComponent(postId)}/${clipId}.${ext}`
: `audio/${new Date().toISOString().slice(0,10)}/${crypto.randomUUID()}.${ext}`;
console.log('[API] Uploading file', { mime, size: req.file.size, bucket, key, postId: postId || '(none)', clipId });
const out = await uploadBuffer({
bucket,
@ -121,8 +126,22 @@ router.post('/audio', upload.single('audio'), async (
contentType: mime,
});
// If postId provided, insert into audio_clips table
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]
);
} catch (e) {
console.error('[API] DB insert audio_clip failed:', e);
// continue anyway, response still returns S3 info
}
}
console.log('[API] Upload success', out);
return res.status(200).json({ success: true, ...out });
return res.status(200).json({ success: true, ...out, clipId: postId ? clipId : undefined, postId: postId || undefined });
} catch (err) {
console.error('Upload failed:', err);
return res.status(500).json({ error: 'Upload failed' });

129
apps/api/src/posts.ts Normal file
View File

@ -0,0 +1,129 @@
import express from 'express';
import crypto from 'crypto';
import { db } from './db';
import { posts, audioClips } from './db/schema';
import { desc, eq } from 'drizzle-orm';
const router = express.Router();
// List posts (minimal info)
router.get('/', async (_req, res) => {
try {
const rows = await db
.select({ id: posts.id, title: posts.title, status: posts.status, updatedAt: posts.updatedAt })
.from(posts)
.orderBy(desc(posts.updatedAt))
.limit(200);
return res.json({ items: rows });
} 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' });
}
});
export default router;

View File

@ -1,12 +1,13 @@
import express from 'express';
import { fetch, FormData } from 'undici';
import { downloadObject } from './storage/s3';
import { pool } from './db';
const router = express.Router();
router.post('/', async (req, res) => {
try {
const { bucket: bodyBucket, key } = req.body as { bucket?: string; key?: string };
const { bucket: bodyBucket, key, postId, clipId } = req.body as { bucket?: string; key?: string; postId?: string; clipId?: string };
const bucket = bodyBucket || process.env.S3_BUCKET;
if (!bucket || !key) {
return res.status(400).json({ error: 'bucket (or env S3_BUCKET) and key are required' });
@ -48,8 +49,18 @@ router.post('/', async (req, res) => {
}
const data: any = await resp.json();
const transcript: string = data.text || '';
// 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]);
} catch (e) {
console.error('DB update transcript failed:', e);
}
}
// OpenAI returns { text: "..." }
return res.json({ success: true, transcript: data.text || '' });
return res.json({ success: true, transcript });
} catch (err: any) {
console.error('STT error:', err);
return res.status(500).json({ error: 'STT error' });

View File

@ -1,5 +1,5 @@
{
"id": "31ba935b-4424-4226-9f8b-803d401022a2",
"content": "<pre><code>enasdasdasd</code></pre><p>zdfsdfsadsdfsdfsdf</p><p></p><p></p><p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"abc\">asdasd</a></p><img src=\"/api/media/obj?bucket=voxblog&amp;key=images%2F2025-10-24%2F15962af6-52ae-4c16-918d-86b9e6488bfa.png\" alt=\"Vector-2.png\"><p></p><p></p><ul><li><p>df</p></li></ul><p></p><p></p><p></p><p></p><p></p>",
"updatedAt": "2025-10-24T12:11:46.031Z"
"updatedAt": "2025-10-24T12:45:17.253Z"
}