Posts Endpoints
The posts endpoints are the core of Bulletin. This lesson builds the complete CRUD API for posts plus the upvote toggle.
Posts router
Section titled “Posts router”import { Router } from 'express'import { listPosts, getPost, createPost, deletePost, toggleUpvote } from '../controllers/posts.js'import { authenticate } from '../middleware/authenticate.js'import { requireFields } from '../middleware/validate.js'
const router = Router()
router.get('/', listPosts)router.get('/:id', getPost)router.post('/', authenticate, requireFields('title', 'body'), createPost)router.delete('/:id', authenticate, deletePost)router.post('/:id/upvote', authenticate, toggleUpvote)
export default routerPosts controller
Section titled “Posts controller”import { Request, Response } from 'express'import { db } from '../db/index.js'
interface PostRow { id: number; title: string; body: string user_id: number; upvotes: number; created_at: string author_username: string}
// GET /postsexport function listPosts(req: Request, res: Response) { const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 50) const page = Math.max(parseInt(req.query.page as string || '1', 10), 1) const offset = (page - 1) * limit const sortField = req.query.sort === 'top' ? 'upvotes DESC' : 'posts.created_at DESC'
const posts = db.prepare(` SELECT posts.*, users.username AS author_username FROM posts JOIN users ON posts.user_id = users.id ORDER BY ${sortField} LIMIT ? OFFSET ? `).all(limit, offset) as PostRow[]
const { total } = db.prepare('SELECT COUNT(*) as total FROM posts').get() as { total: number }
res.json({ posts, total, page, limit })}
// GET /posts/:idexport function getPost(req: Request, res: Response) { const post = db.prepare(` SELECT posts.*, users.username AS author_username FROM posts JOIN users ON posts.user_id = users.id WHERE posts.id = ? `).get(parseInt(req.params.id)) as PostRow | undefined
if (!post) return res.status(404).json({ error: 'Post not found' }) res.json(post)}
// POST /postsexport function createPost(req: Request, res: Response) { const { title, body } = req.body as { title: string; body: string } const { userId } = res.locals.user
const result = db.prepare( 'INSERT INTO posts (title, body, user_id) VALUES (?, ?, ?)' ).run(title.trim(), body.trim(), userId)
const post = db.prepare(` SELECT posts.*, users.username AS author_username FROM posts JOIN users ON posts.user_id = users.id WHERE posts.id = ? `).get(result.lastInsertRowid) as PostRow
res.status(201).json(post)}
// DELETE /posts/:idexport function deletePost(req: Request, res: Response) { const { userId } = res.locals.user const postId = parseInt(req.params.id)
const result = db.prepare( 'DELETE FROM posts WHERE id = ? AND user_id = ?' ).run(postId, userId)
if (result.changes === 0) { return res.status(404).json({ error: 'Post not found or not yours' }) } res.sendStatus(204)}
// POST /posts/:id/upvoteexport function toggleUpvote(req: Request, res: Response) { const { userId } = res.locals.user const postId = parseInt(req.params.id)
const existing = db.prepare( 'SELECT 1 FROM post_upvotes WHERE user_id = ? AND post_id = ?' ).get(userId, postId)
const upvoteTransaction = db.transaction(() => { if (existing) { db.prepare('DELETE FROM post_upvotes WHERE user_id = ? AND post_id = ?').run(userId, postId) db.prepare('UPDATE posts SET upvotes = upvotes - 1 WHERE id = ?').run(postId) return false // removed upvote } else { db.prepare('INSERT INTO post_upvotes (user_id, post_id) VALUES (?, ?)').run(userId, postId) db.prepare('UPDATE posts SET upvotes = upvotes + 1 WHERE id = ?').run(postId) return true // added upvote } })
const upvoted = upvoteTransaction() const post = db.prepare('SELECT upvotes FROM posts WHERE id = ?').get(postId) as { upvotes: number } res.json({ upvoted, upvotes: post.upvotes })}Testing
Section titled “Testing”# Create a post (need token from registration)curl -X POST http://localhost:3000/posts \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title": "Hello Bulletin", "body": "My first post"}'
# List postscurl http://localhost:3000/posts
# Upvotecurl -X POST http://localhost:3000/posts/1/upvote \ -H "Authorization: Bearer YOUR_TOKEN"Exercise
Section titled “Exercise”- Build the complete posts router and controller.
- Test all five endpoints with curl or Thunder Client.
- Confirm you can’t delete someone else’s post.
- Upvote a post twice — confirm the count increases by 1 on the first and decreases on the second (toggle behavior).
- Public routes (
GET /posts,GET /posts/:id) need no authentication. - Protected routes (
POST,DELETE, upvote) require theauthenticatemiddleware. DELETEusesAND user_id = ?to enforce ownership — the database handles authorization.- The upvote toggle uses a transaction to atomically update both tables.
- Always return full data (including joined
author_username) rather than just IDs.