feat: add MySQL database integration with Drizzle ORM for post and audio clip storage
This commit is contained in:
parent
3f2d3f0e8f
commit
038591c9cf
@ -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
29
apps/api/src/db.ts
Normal 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
35
apps/api/src/db/schema.ts
Normal 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(),
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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
129
apps/api/src/posts.ts
Normal 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;
|
||||
@ -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' });
|
||||
|
||||
@ -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&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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user