From 038591c9cf63c1bfd1350a652d495678090133c4 Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 14:57:54 +0200 Subject: [PATCH] feat: add MySQL database integration with Drizzle ORM for post and audio clip storage --- apps/api/package.json | 2 + apps/api/src/db.ts | 29 ++++ apps/api/src/db/schema.ts | 35 +++++ apps/api/src/drafts.ts | 11 ++ apps/api/src/index.ts | 2 + apps/api/src/media.ts | 27 +++- apps/api/src/posts.ts | 129 ++++++++++++++++++ apps/api/src/stt.ts | 15 +- .../31ba935b-4424-4226-9f8b-803d401022a2.json | 2 +- 9 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/db.ts create mode 100644 apps/api/src/db/schema.ts create mode 100644 apps/api/src/posts.ts diff --git a/apps/api/package.json b/apps/api/package.json index 5b69197..b34dd79 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..2d5ee11 --- /dev/null +++ b/apps/api/src/db.ts @@ -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(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' }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..d42cc0c --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -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(), +}); diff --git a/apps/api/src/drafts.ts b/apps/api/src/drafts.ts index 8f4aeda..28b4e6b 100644 --- a/apps/api/src/drafts.ts +++ b/apps/api/src/drafts.ts @@ -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); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b6d902d..9be2882 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 }); diff --git a/apps/api/src/media.ts b/apps/api/src/media.ts index f3aea5e..fd86251 100644 --- a/apps/api/src/media.ts +++ b/apps/api/src/media.ts @@ -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' }); diff --git a/apps/api/src/posts.ts b/apps/api/src/posts.ts new file mode 100644 index 0000000..648d449 --- /dev/null +++ b/apps/api/src/posts.ts @@ -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; diff --git a/apps/api/src/stt.ts b/apps/api/src/stt.ts index 5a38365..c18024a 100644 --- a/apps/api/src/stt.ts +++ b/apps/api/src/stt.ts @@ -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' }); diff --git a/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json b/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json index b699780..8747203 100644 --- a/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json +++ b/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json @@ -1,5 +1,5 @@ { "id": "31ba935b-4424-4226-9f8b-803d401022a2", "content": "
enasdasdasd

zdfsdfsadsdfsdfsdf

asdasd

\"Vector-2.png\"

", - "updatedAt": "2025-10-24T12:11:46.031Z" + "updatedAt": "2025-10-24T12:45:17.253Z" } \ No newline at end of file