Posts CRUD

This commit is contained in:
2024-12-01 16:46:14 +00:00
parent 88e64e0885
commit bacb955108
2 changed files with 241 additions and 102 deletions

View File

@@ -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' });
}
}
});
}

View File

@@ -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 () => {