From bacb955108a8fec0e30ad6cea0dd0e5c478d96e5 Mon Sep 17 00:00:00 2001 From: rishi Date: Sun, 1 Dec 2024 16:46:14 +0000 Subject: [PATCH] Posts CRUD --- server/src/routes/posts.js | 336 ++++++++++++++++++++++++++----------- server/src/server.js | 7 +- 2 files changed, 241 insertions(+), 102 deletions(-) diff --git a/server/src/routes/posts.js b/server/src/routes/posts.js index a07d0a6..52cea5b 100644 --- a/server/src/routes/posts.js +++ b/server/src/routes/posts.js @@ -1,124 +1,266 @@ +// File: src/routes/posts.js import { marked } from 'marked'; import slugify from 'slugify'; import { db } from '../db.js'; -export default async function posts(fastify, options) { - fastify.addHook('preHandler', async (request, reply) => { - try { - await request.jwtVerify(); - } catch (err) { - reply.send(err); +// Auth middleware helper +const authenticate = async (request, reply) => { + try { + await request.jwtVerify(); + } catch (err) { + reply.code(401).send({ error: 'Unauthorized' }); + } +}; + +export default async function postRoutes(fastify) { + // Create post + fastify.post('/api/posts', { + onRequest: [authenticate], + handler: async (request, reply) => { + const { title, content, status = 'draft', publishedAt, tags = [] } = request.body; + + try { + const post = await db.tx(async t => { + // Create slug from title + const baseSlug = slugify(title, { lower: true }); + let slug = baseSlug; + let counter = 1; + + // Check for slug uniqueness + while (await t.oneOrNone('SELECT id FROM posts WHERE slug = $1', [slug])) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Insert post + const post = await t.one( + `INSERT INTO posts ( + title, slug, markdown_content, compiled_content, + status, published_at, author_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + title, + slug, + content, + marked(content), + status, + publishedAt || null, + request.user.id + ] + ); + + // Handle tags + if (tags.length > 0) { + for (const tagName of tags) { + // Get or create tag + const tag = await t.oneOrNone( + 'SELECT * FROM tags WHERE name = $1', + [tagName] + ) || await t.one( + 'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING *', + [tagName, slugify(tagName, { lower: true })] + ); + + // Create post-tag relationship + await t.none( + 'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)', + [post.id, tag.id] + ); + } + } + + return post; + }); + + reply.code(201).send(post); + } catch (err) { + fastify.log.error(err); + reply.code(500).send({ error: 'Internal server error' }); + } } }); - // Create post - fastify.post('/api/posts', async (request, reply) => { - const { title, content, status, publishedAt, tags } = request.body; - const slug = slugify(title, { lower: true }); - const compiledContent = marked(content); + // Get all posts + fastify.get('/api/posts', { + onRequest: [authenticate], + handler: async (request, reply) => { + const { status, tag } = request.query; + + try { + let query = ` + SELECT p.*, + json_agg(DISTINCT jsonb_build_object( + 'id', t.id, + 'name', t.name, + 'slug', t.slug + )) FILTER (WHERE t.id IS NOT NULL) as tags, + json_agg(DISTINCT i.url) FILTER (WHERE i.id IS NOT NULL) as images + FROM posts p + LEFT JOIN posts_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + LEFT JOIN images i ON p.id = i.post_id + WHERE p.author_id = $1 + `; - const post = await db.tx(async t => { - const post = await t.one( - `INSERT INTO posts - (title, slug, markdown_content, compiled_content, status, published_at, author_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [title, slug, content, compiledContent, status, publishedAt, request.user.id] - ); + const params = [request.user.id]; - if (tags && tags.length) { - for (const tagName of tags) { - const tag = await t.oneOrNone( - 'SELECT * FROM tags WHERE name = $1', - [tagName] - ); - - const tagId = tag ? tag.id : ( - await t.one( - 'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING id', - [tagName, slugify(tagName, { lower: true })] - ) - ).id; - - await t.none( - 'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)', - [post.id, tagId] - ); + if (status) { + query += ` AND p.status = $${params.push(status)}`; } + + if (tag) { + query += ` AND EXISTS ( + SELECT 1 FROM posts_tags pt2 + JOIN tags t2 ON pt2.tag_id = t2.id + WHERE pt2.post_id = p.id AND t2.slug = $${params.push(tag)} + )`; + } + + query += ` + GROUP BY p.id + ORDER BY p.created_at DESC + `; + + const posts = await db.any(query, params); + reply.send(posts); + } catch (err) { + fastify.log.error(err); + reply.code(500).send({ error: 'Internal server error' }); } - - return post; - }); - - reply.send(post); + } }); - // Get all posts - fastify.get('/api/posts', async (request, reply) => { - const posts = await db.any(` - SELECT p.*, - array_agg(DISTINCT t.name) as tags, - array_agg(DISTINCT i.url) as images - FROM posts p - LEFT JOIN posts_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - LEFT JOIN images i ON p.id = i.post_id - WHERE p.author_id = $1 - GROUP BY p.id - ORDER BY p.created_at DESC - `, [request.user.id]); + // Get single post + fastify.get('/api/posts/:id', { + onRequest: [authenticate], + handler: async (request, reply) => { + const { id } = request.params; - reply.send(posts); + try { + const post = await db.oneOrNone(` + SELECT p.*, + json_agg(DISTINCT jsonb_build_object( + 'id', t.id, + 'name', t.name, + 'slug', t.slug + )) FILTER (WHERE t.id IS NOT NULL) as tags, + json_agg(DISTINCT i.url) FILTER (WHERE i.id IS NOT NULL) as images + FROM posts p + LEFT JOIN posts_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + LEFT JOIN images i ON p.id = i.post_id + WHERE p.id = $1 AND p.author_id = $2 + GROUP BY p.id`, + [id, request.user.id] + ); + + if (!post) { + reply.code(404).send({ error: 'Post not found' }); + return; + } + + reply.send(post); + } catch (err) { + fastify.log.error(err); + reply.code(500).send({ error: 'Internal server error' }); + } + } }); // Update post - fastify.put('/api/posts/:id', async (request, reply) => { - const { id } = request.params; - const { title, content, status, publishedAt, tags } = request.body; - const compiledContent = marked(content); + fastify.put('/api/posts/:id', { + onRequest: [authenticate], + handler: async (request, reply) => { + const { id } = request.params; + const { title, content, status, publishedAt, tags = [] } = request.body; - const post = await db.tx(async t => { - const post = await t.one( - `UPDATE posts - SET title = $1, - markdown_content = $2, - compiled_content = $3, - status = $4, - published_at = $5, - updated_at = CURRENT_TIMESTAMP - WHERE id = $6 AND author_id = $7 - RETURNING *`, - [title, content, compiledContent, status, publishedAt, id, request.user.id] - ); - - // Remove existing tags - await t.none('DELETE FROM posts_tags WHERE post_id = $1', [id]); - - // Add new tags - if (tags && tags.length) { - for (const tagName of tags) { - const tag = await t.oneOrNone( - 'SELECT * FROM tags WHERE name = $1', - [tagName] + try { + const post = await db.tx(async t => { + // Verify post exists and belongs to user + const existingPost = await t.oneOrNone( + 'SELECT id FROM posts WHERE id = $1 AND author_id = $2', + [id, request.user.id] ); - const tagId = tag ? tag.id : ( - await t.one( - 'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING id', + if (!existingPost) { + reply.code(404).send({ error: 'Post not found' }); + return; + } + + // Update post + const post = await t.one( + `UPDATE posts SET + title = $1, + markdown_content = $2, + compiled_content = $3, + status = $4, + published_at = $5, + updated_at = CURRENT_TIMESTAMP + WHERE id = $6 AND author_id = $7 + RETURNING *`, + [ + title, + content, + marked(content), + status, + publishedAt, + id, + request.user.id + ] + ); + + // Update tags + await t.none('DELETE FROM posts_tags WHERE post_id = $1', [id]); + + for (const tagName of tags) { + const tag = await t.oneOrNone( + 'SELECT * FROM tags WHERE name = $1', + [tagName] + ) || await t.one( + 'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING *', [tagName, slugify(tagName, { lower: true })] - ) - ).id; + ); - await t.none( - 'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)', - [id, tagId] - ); - } + await t.none( + 'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)', + [id, tag.id] + ); + } + + return post; + }); + + reply.send(post); + } catch (err) { + fastify.log.error(err); + reply.code(500).send({ error: 'Internal server error' }); } + } + }); - return post; - }); + // Delete post + fastify.delete('/api/posts/:id', { + onRequest: [authenticate], + handler: async (request, reply) => { + const { id } = request.params; - reply.send(post); + try { + const result = await db.result( + 'DELETE FROM posts WHERE id = $1 AND author_id = $2', + [id, request.user.id] + ); + + if (result.rowCount === 0) { + reply.code(404).send({ error: 'Post not found' }); + return; + } + + reply.code(204).send(); + } catch (err) { + fastify.log.error(err); + reply.code(500).send({ error: 'Internal server error' }); + } + } }); } \ No newline at end of file diff --git a/server/src/server.js b/server/src/server.js index d404521..a5a4dc3 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'; import { dirname } from 'path'; import dotenv from 'dotenv'; import authRoutes from './routes/auth.js'; +import postRoutes from './routes/posts.js'; // Load environment variables dotenv.config(); @@ -45,11 +46,7 @@ await app.register(fastifyStatic, { // Register routes await app.register(authRoutes); - -// Testing route -app.get('/', async (request, reply) => { - return { hello: 'world' } -}); +await app.register(postRoutes); // Start the server const start = async () => {