Skip to content

Commit

Permalink
feat(client): blog feature (#276)
Browse files Browse the repository at this point in the history
* feat: blog post creation

* feat: add policy to storage

* feat: set default image placeholder for blog post

* feat: blog post page and front end for comment section

* feat: comment list

* feat: add comment to a post

* feat: user posts management page

* feat: delete post

* feat: visualisation for post creation

* feat: blog posts filter

* feat: filter posts and search

* fix: report test

* fix: post equality in reports test
  • Loading branch information
Metololo authored Jul 21, 2024
1 parent 2f9004a commit a042821
Show file tree
Hide file tree
Showing 25 changed files with 1,311 additions and 100 deletions.
58 changes: 52 additions & 6 deletions apps/api/src/handlers/blog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import { uploadFile } from '../libs/storage.js';
import { supabase } from '../libs/supabase.js';
import { zodErrorHook } from '../libs/zodError.js';
import {
Expand All @@ -24,7 +25,7 @@ export const blog = new OpenAPIHono<{ Variables: Variables }>({
});

blog.openapi(getAllPosts, async (c) => {
const { all, search, skip, take } = c.req.valid('query');
const { all, search, skip, take, userId } = c.req.valid('query');

const query = supabase
.from('POSTS')
Expand All @@ -51,6 +52,10 @@ blog.openapi(getAllPosts, async (c) => {
query.range(from, to);
}

if (userId) {
query.eq('id_user', userId);
}

const { data, error, count } = await query;

if (error || !data) {
Expand Down Expand Up @@ -78,31 +83,72 @@ blog.openapi(getAllPosts, async (c) => {

blog.openapi(getPost, async (c) => {
const { id } = c.req.valid('param');
const { data, error } = await supabase.from('POSTS').select('*').eq('id', id).single();

const { data, error } = await supabase
.from('POSTS')
.select(
`id,title,content,created_at,cover_image,description,
author:USERS!public_POSTS_user_id_fkey(id,username),
categories:POSTS_CATEGORIES(CATEGORIES(id,name)),
comments_number:COMMENTS(count),
views_number:POSTS_VIEWS(count),
likes_number:POSTS_REACTIONS(count),
comments:COMMENTS(id,content,created_at,author:USERS!public_COMMENTS_id_user_fkey(id,username)),
reports:REPORTS(id)`,
)
.eq('id', id)
.single();

if (error || !data) {
return c.json({ error: 'Post not found' }, 404);
}

return c.json(data, 200);
const finalData = {
...data,
author: data.author ? { id: data.author.id, username: data.author.username } : null,
categories: data.categories
? data.categories.map((category) => ({
id: category.CATEGORIES?.id ?? null,
name: category.CATEGORIES?.name ?? null,
}))
: [],
comments_number: data.comments_number[0]?.count || 0,
views_number: data.views_number[0]?.count || 0,
likes_number: data.likes_number[0]?.count || 0,
};

return c.json(finalData, 200);
});

blog.openapi(createPost, async (c) => {
const { title, content, cover_image, description } = c.req.valid('json');
const { title, content, cover_image, description } = c.req.valid('form');
const user = c.get('user');
const id_user = user.id;
await checkRole(user.roles, false, [Role.REDACTOR, Role.MODERATOR]);
await checkRole(user.roles, false, [Role.REDACTOR, Role.MODERATOR, Role.ADMIN]);

let coverImageName = '';
if (cover_image) {
try {
coverImageName = crypto.randomUUID();
} catch (exception) {
console.log(exception);
}
}

const { data, error } = await supabase
.from('POSTS')
.insert({ title, content, id_user, cover_image, description })
.insert({ title, content, id_user, cover_image: coverImageName, description })
.select()
.single();

if (error || !data) {
return c.json({ error: 'Failed to create post' }, 400);
}

if (cover_image) {
await uploadFile(`blog_posts/${coverImageName}`, cover_image, 'image');
}

return c.json(data, 201);
});

Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/libs/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ export async function uploadFile(
file: File,
bucket: string,
): Promise<{ error?: string; data?: unknown }> {
const { data, error } = await supabase.storage.from(bucket).upload(path, file);
const { data, error } = await supabase.storage.from(bucket).upload(path, file, {
cacheControl: '3600',
upsert: false,
});
if (error) {
console.error('Error uploading file:', error.message);
return { error: error.message };
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/routes/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
insertPostSchema,
insertResponseSchema,
postCardListSchemaResponse,
postCardSchemaResponse,
postSchema,
responseSchema,
updatePostSchema,
Expand All @@ -19,7 +20,7 @@ export const getAllPosts = createRoute({
summary: 'Get all posts',
description: 'Get all posts',
request: {
query: queryAllSchema,
query: queryAllSchema.extend({ userId: z.coerce.number().min(1).optional() }),
},
responses: {
200: {
Expand Down Expand Up @@ -51,7 +52,7 @@ export const getPost = createRoute({
description: 'Successful response',
content: {
'application/json': {
schema: postSchema,
schema: postCardSchemaResponse,
},
},
},
Expand All @@ -71,7 +72,7 @@ export const createPost = createRoute({
request: {
body: {
content: {
'application/json': {
'multipart/form-data': {
schema: insertPostSchema,
},
},
Expand Down
41 changes: 36 additions & 5 deletions apps/api/src/validators/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,40 @@ export const postCardSchemaResponse = z.object({
likes_number: z.number().min(0),
});

export const postCommentSchema = z.object({
id: z.number().min(1),
content: z.string().min(1),
created_at: z.string(),
author: z.object({
id: z.number().min(1),
username: z.string(),
}),
});

export const singlePostSchema = z.object({
id: z.number().min(1),
title: z.string(),
content: z.string(),
created_at: z.string(),
cover_image: z.string().nullable(),
description: z.string().nullable(),
author: z.object({ id: z.number(), username: z.string() }).nullable(),
categories: z.object({ id: z.number().nullable(), name: z.string().nullable() }).array(),
comments: postCommentSchema.array(),
reports: z.object({ id: z.number() }).array(),
comments_number: z.number().min(0),
views_number: z.number().min(0),
likes_number: z.number().min(0),
});

export const postCardListSchemaResponse = z.array(postCardSchemaResponse);

export const insertPostSchema = postSchema.omit({ id: true });
export const insertPostSchema = z.object({
title: z.string(),
description: z.string().optional(),
content: z.string(),
cover_image: z.instanceof(File).optional(),
});

export const updatePostSchema = z.object({
title: z.string().optional(),
Expand All @@ -39,10 +70,6 @@ export const responseSchema = z.object({
content: z.string().max(255),
});

export const insertResponseSchema = z.object({
content: z.string().max(255),
});

export const commentSchema = z.object({
id: z.number().min(1),
content: z.string().max(255),
Expand All @@ -53,4 +80,8 @@ export const commentSchema = z.object({
id_user: z.number().min(1),
});

export const insertResponseSchema = z.object({
content: z.string().max(255),
});

export const insertCommentSchema = insertResponseSchema;
27 changes: 18 additions & 9 deletions apps/api/test/blog.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import app from '../src/index.js';
import { Role } from '../src/validators/general.js';
import { deleteAdmin, insertRole, setValidSubscription } from './utils.js';
Expand All @@ -8,6 +10,7 @@ describe('Blog tests', () => {
let jwt: string;
let id_post: number;
let id_comment: number;
let postImage: string;

beforeAll(async () => {
const res = await app.request('/auth/signup', {
Expand Down Expand Up @@ -44,22 +47,28 @@ describe('Blog tests', () => {

describe('Post operations', () => {
test('Create post', async () => {
const formData = new FormData();
formData.append('title', 'Post test');
formData.append('description', 'Post test description');
formData.append('content', 'Post test content');

const imagePath = path.join(__dirname, 'files', 'mock_image.png');
const imageBuffer = fs.readFileSync(imagePath);
const imageBlob = new Blob([imageBuffer], { type: 'image/png' });
formData.append('cover_image', imageBlob, 'mock_image.png');

const res = await app.request('/blog/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({
title: 'Post test',
content: 'Post test content',
cover_image: 'https://example.com/image.jpg',
description: 'Post test description',
}),
body: formData,
});

expect(res.status).toBe(201);
const post: { id: number; title: string; content: string } = await res.json();
const post: { id: number; title: string; content: string; cover_image: string } = await res.json();
id_post = post.id;
postImage = post.cover_image;
expect(post.title).toBe('Post test');
expect(post.content).toBe('Post test content');
});
Expand All @@ -76,7 +85,7 @@ describe('Blog tests', () => {
expect(post).toMatchObject({
title: 'Post test',
content: 'Post test content',
cover_image: 'https://example.com/image.jpg',
cover_image: postImage,
description: 'Post test description',
});
});
Expand Down
30 changes: 20 additions & 10 deletions apps/api/test/reports.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import app from '../src/index.js';
import { Role } from '../src/validators/general.js';
import { deleteAdmin, insertRole, setValidSubscription } from './utils.js';
Expand Down Expand Up @@ -42,22 +44,30 @@ describe('Report tests', () => {
const loginUser: { token: string } = await loginRes.json();
jwt = loginUser.token;

const postRes = await app.request('/blog/posts', {
const formData = new FormData();

formData.append('title', 'Post test report');
formData.append('description', 'Post test description');
formData.append('content', 'Post test content for reporting');

const imagePath = path.join(__dirname, 'files', 'mock_image.png');
const imageBuffer = fs.readFileSync(imagePath);
const imageBlob = new Blob([imageBuffer], { type: 'image/png' });
formData.append('cover_image', imageBlob, 'mock_image.png');

const resPost = await app.request('/blog/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({
title: 'Post test report',
content: 'Post test content for reporting',
cover_image: 'https://example.com/image.jpg',
description: 'Post test description',
}),
body: formData,
});
expect(postRes.status).toBe(201);
const post = await postRes.json();

expect(resPost.status).toBe(201);
const post: { id: number; title: string; content: string; cover_image: string } = await resPost.json();
id_post = post.id;
expect(post.title).toBe('Post test report');
expect(post.content).toBe('Post test content for reporting');

const commentRes = await app.request(`/blog/posts/${id_post}/comments`, {
method: 'POST',
Expand Down
Loading

0 comments on commit a042821

Please sign in to comment.