Posts CRUD
This commit is contained in:
@@ -1,124 +1,266 @@
|
|||||||
|
// File: src/routes/posts.js
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
import { db } from '../db.js';
|
import { db } from '../db.js';
|
||||||
|
|
||||||
export default async function posts(fastify, options) {
|
// Auth middleware helper
|
||||||
fastify.addHook('preHandler', async (request, reply) => {
|
const authenticate = async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply.send(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
|
// Get all posts
|
||||||
fastify.post('/api/posts', async (request, reply) => {
|
fastify.get('/api/posts', {
|
||||||
const { title, content, status, publishedAt, tags } = request.body;
|
onRequest: [authenticate],
|
||||||
const slug = slugify(title, { lower: true });
|
handler: async (request, reply) => {
|
||||||
const compiledContent = marked(content);
|
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 params = [request.user.id];
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tags && tags.length) {
|
if (status) {
|
||||||
for (const tagName of tags) {
|
query += ` AND p.status = $${params.push(status)}`;
|
||||||
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 (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
|
// Get single post
|
||||||
fastify.get('/api/posts', async (request, reply) => {
|
fastify.get('/api/posts/:id', {
|
||||||
const posts = await db.any(`
|
onRequest: [authenticate],
|
||||||
SELECT p.*,
|
handler: async (request, reply) => {
|
||||||
array_agg(DISTINCT t.name) as tags,
|
const { id } = request.params;
|
||||||
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]);
|
|
||||||
|
|
||||||
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
|
// Update post
|
||||||
fastify.put('/api/posts/:id', async (request, reply) => {
|
fastify.put('/api/posts/:id', {
|
||||||
const { id } = request.params;
|
onRequest: [authenticate],
|
||||||
const { title, content, status, publishedAt, tags } = request.body;
|
handler: async (request, reply) => {
|
||||||
const compiledContent = marked(content);
|
const { id } = request.params;
|
||||||
|
const { title, content, status, publishedAt, tags = [] } = request.body;
|
||||||
|
|
||||||
const post = await db.tx(async t => {
|
try {
|
||||||
const post = await t.one(
|
const post = await db.tx(async t => {
|
||||||
`UPDATE posts
|
// Verify post exists and belongs to user
|
||||||
SET title = $1,
|
const existingPost = await t.oneOrNone(
|
||||||
markdown_content = $2,
|
'SELECT id FROM posts WHERE id = $1 AND author_id = $2',
|
||||||
compiled_content = $3,
|
[id, request.user.id]
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagId = tag ? tag.id : (
|
if (!existingPost) {
|
||||||
await t.one(
|
reply.code(404).send({ error: 'Post not found' });
|
||||||
'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING id',
|
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 })]
|
[tagName, slugify(tagName, { lower: true })]
|
||||||
)
|
);
|
||||||
).id;
|
|
||||||
|
|
||||||
await t.none(
|
await t.none(
|
||||||
'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)',
|
'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)',
|
||||||
[id, tagId]
|
[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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
|
import postRoutes from './routes/posts.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -45,11 +46,7 @@ await app.register(fastifyStatic, {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
|
await app.register(postRoutes);
|
||||||
// Testing route
|
|
||||||
app.get('/', async (request, reply) => {
|
|
||||||
return { hello: 'world' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user