diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b34afdb7..c00a693f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - name: Use Yarn Cache uses: actions/cache@v3 with: diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 3a7e6423..99751372 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -12,6 +12,10 @@ jobs: environment: prod steps: - uses: actions/checkout@v2 + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: 18 - run: yarn install - run: yarn workspace frontend build env: diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 08502fcb..f712251f 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - run: yarn install - run: yarn workspace frontend build env: diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..12d75ec0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -17,6 +17,9 @@ import { QuestionForm, QuestionFormWithId, LocationTravelTimes, + BlogPost, + BlogPostInternal, + BlogPostWithId, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; @@ -42,7 +45,7 @@ const likesCollection = db.collection('likes'); const usersCollection = db.collection('users'); const pendingBuildingsCollection = db.collection('pendingBuildings'); const contactQuestionsCollection = db.collection('contactQuestions'); - +const blogPostCollection = db.collection('blogposts'); const travelTimesCollection = db.collection('travelTimes'); // Middleware setup @@ -68,6 +71,288 @@ app.get('/api/faqs', async (_, res) => { res.status(200).send(JSON.stringify(faqs)); }); +/** + * new-blog-post – Creates a new blog post. + * + * @remarks + * This endpoint creates and adds a new blog post into the products database. If necessary data are not given, the endpoint + * will result in an error. Certain fields are defaulted to constant values. + * + * @route POST /api/new-blog-post + * + * @status + * - 201: Successfully created new blog post. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.post('/api/new-blog-post', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + const realUserId = req.user.uid; + try { + const doc = blogPostCollection.doc(); + const blogPost = req.body as BlogPost; + if ( + blogPost.content === '' || + blogPost.blurb === '' || + blogPost.title === '' || + !blogPost.coverImageUrl || + !blogPost.tags + ) { + res.status(401).send('Error: missing fields'); + } + doc.set({ + ...blogPost, + date: new Date(), + likes: 0, + saves: 0, + userId: realUserId, + }); + return res.status(201).send(doc.id); + } catch (err) { + console.error(err); + return res.status(401).send('Error'); + } +}); + +/** + * delete-blog-post/:blogPostId – Deletes a specified blog post. + * + * @remarks + * This endpoint deletes a specified blog post from its ID. The post is removed from the products database. + * If no blog post is found or the user is not authorized to delete the review, then an error is thrown. + * + * @route PUT /api/delete-blog-post/:blogPostId + * + * @status + * - 200: Successfully deleted the specified blog post. + * - 404: Blog post could not be found from ID. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.put('/api/delete-blog-post/:blogPostId', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + const { blogPostId } = req.params; // Extract the blog post document ID from the request parameters + const { email } = req.user; + // Check if the user is an admin or the creator of the blog post + const blogPostDoc = blogPostCollection.doc(blogPostId); + const blogPostData = (await blogPostDoc.get()).data(); + if (!blogPostData) { + res.status(404).send('Blog Post not found'); + return; + } + if (!(email && admins.includes(email))) { + res.status(403).send('Unauthorized'); + return; + } + try { + // Update the status of the blog post document to 'DELETED' + await blogPostCollection.doc(blogPostId).update({ visibility: 'ARCHIVED' }); + // Send a success response + res.status(200).send('Success'); + } catch (err) { + // Handle any errors that may occur during the deletion process + console.log(err); + res.status(401).send('Error'); + } +}); + +/** + * edit-blog-post/:blogPostId – Edits a specified blog post. + * + * @remarks + * This endpoint edits a specified blog post from its ID. The post is edited from the products database. + * If no blog post is found or the user is not authorized to edit the review, then an error is thrown. + * + * @route POST /api/edit-blog-post/:blogPostId + * + * @status + * - 201: Successfully edited the specified blog post. + * - 404: Blog post could not be found from ID. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.post('/api/edit-blog-post/:blogPostId', authenticate, async (req, res) => { + // if (!req.user) { + // throw new Error('not authenticated'); + // } + const { blogPostId } = req.params; + // const { email } = req.user; + try { + const blogPostDoc = blogPostCollection.doc(blogPostId); // specific doc for the id + const blogPostData = (await blogPostDoc.get()).data(); + if (!blogPostData) { + res.status(404).send('Blog Post not found'); + return; + } + // if (!(email && admins.includes(email))) { + // res.status(401).send('Error: user is not an admin. Not authorized'); + // return; + // } + const updatedBlogPost = req.body as BlogPost; + if (updatedBlogPost.content === '' || updatedBlogPost.title === '') { + res.status(401).send('Error: missing fields'); + } + blogPostDoc + .update({ + ...updatedBlogPost, + date: new Date(updatedBlogPost.date), + }) + .then(() => { + res.status(201).send(blogPostId); + }); + } catch (err) { + console.error(err); + res.status(401).send('Error'); + } +}); + +/** + * blog-post-by-id/:blogPostId – Gets a specified blog post. + * + * @remarks + * This endpoint gets a specified blog post from its ID. The post is grabbed from the products database. + * If no blog post is found, then an error is thrown. + * + * @route GET /api/blog-post-by-id/:blogPostId + * + * @status + * - 200: Successfully edited the specified blog post. + * - 404: Blog post could not be found from ID. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.get('/api/blog-post-by-id/:blogPostId', async (req, res) => { + const { blogPostId } = req.params; + + try { + const blogPostDoc = await blogPostCollection.doc(blogPostId).get(); + if (!blogPostDoc.exists) { + res.status(404).send('Blog Post not found'); + return; + } + + const data = blogPostDoc.data(); + + let blogPost: BlogPostInternal; + if (data?.date && typeof (data.date as any).toDate === 'function') { + // Firestore Timestamp -> Date + blogPost = { ...data, date: data.date.toDate() } as BlogPostInternal; + } else { + // Already a Date or missing + blogPost = { ...data } as BlogPostInternal; + } + + const blogPostWithId = { ...blogPost, id: blogPostDoc.id } as BlogPostWithId; + res.status(200).json(blogPostWithId); + } catch (err) { + console.error('Error retrieving Blog Post', err); + res.status(500).send('Error retrieving Blog Post'); + } +}); + +/** + * blog-post/like/:userId – Fetches blog posts liked by a user. + * + * @remarks + * This endpoint retrieves blog posts that a user has liked. + * + * @route GET /api/blog-post/like/:userId + * + * @status + * - 200: Successfully retrieved the blog posts. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.get('/api/blog-post/like/:userId', authenticate, async (req, res) => { + if (!req.user) { + throw new Error('not authenticated'); + } + const realUserId = req.user.uid; + const { userId } = req.params; + if (userId !== realUserId) { + res.status(401).send("Error: user is not authorized to access another user's likes"); + return; + } + const likesDoc = await likesCollection.doc(userId).get(); + + if (likesDoc.exists) { + const data = likesDoc.data(); + if (data) { + const blogPostIds = data.blogPosts; + const matchingBlogPosts: BlogPostWithId[] = []; + if (blogPostIds.length > 0) { + const query = blogPostCollection.where(FieldPath.documentId(), 'in', blogPostIds); + const querySnapshot = await query.get(); + querySnapshot.forEach((doc) => { + const data = doc.data(); + const blogPostData = { ...data, date: data.date.toDate() }; + matchingBlogPosts.push({ ...blogPostData, id: doc.id } as BlogPostWithId); + }); + } + res.status(200).send(JSON.stringify(matchingBlogPosts)); + return; + } + } + + res.status(200).send(JSON.stringify([])); +}); + +/** + * blog-post/like/:userId – Fetches blog posts saved by a user. + * + * @remarks + * This endpoint retrieves blog posts that a user has saved. + * + * @route GET /api/blog-post/save/:userId + * + * @status + * - 200: Successfully retrieved the blog posts. + * - 401: Error due to unauthorized access or authentication issues. + */ +app.get('/api/blog-post/save/:userId', authenticate, async (req, res) => { + if (!req.user) { + throw new Error('not authenticated'); + } + const realUserId = req.user.uid; + const { userId } = req.params; + if (userId !== realUserId) { + res.status(401).send("Error: user is not authorized to access another user's saves"); + return; + } + const savesDoc = await likesCollection.doc(realUserId).get(); + + if (savesDoc.exists) { + const data = savesDoc.data(); + if (data) { + const blogPostIds = Object.keys(data); + const matchingBlogPosts: BlogPostWithId[] = []; + if (blogPostIds.length > 0) { + const query = blogPostCollection.where(FieldPath.documentId(), 'in', blogPostIds); + const querySnapshot = await query.get(); + querySnapshot.forEach((doc) => { + const data = doc.data(); + const blogPostData = { ...data, date: data.date.toDate() }; + matchingBlogPosts.push({ ...blogPostData, id: doc.id } as BlogPostWithId); + }); + } + res.status(200).send(JSON.stringify(matchingBlogPosts)); + return; + } + } + + res.status(200).send(JSON.stringify([])); +}); + +/** + * blog-posts – Gets all visible blog posts for the Advice page. + * + * @route GET /api/blog-posts + */ +app.get('/api/blog-posts', async (req, res) => { + const blogPostDocs = (await blogPostCollection.where('visibility', '==', 'ACTIVATED').get()).docs; + const blogPosts: BlogPost[] = blogPostDocs.map((doc) => { + const data = doc.data(); + const blogPost = { ...data } as BlogPostInternal; + return { ...blogPost, id: doc.id } as BlogPostWithId; + }); + res.status(200).send(JSON.stringify(blogPosts)); +}); + // API endpoint to post a new review app.post('/api/new-review', authenticate, async (req, res) => { try { @@ -784,10 +1069,10 @@ const likeHandler = t.update(reviewRef, { likes: FieldValue.increment(likeChange) }); } }); - res.status(200).send(JSON.stringify({ result: 'Success' })); + return res.status(200).send(JSON.stringify({ result: 'Success' })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; @@ -861,10 +1146,10 @@ const saveApartmentHandler = t.update(userRef, { apartments: userApartments }); }); - res.status(200).send(JSON.stringify({ result: 'Success' })); + return res.status(200).send(JSON.stringify({ result: 'Success' })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; @@ -906,10 +1191,10 @@ const saveLandlordHandler = t.update(userRef, { landlords: userLandlords }); }); - res.status(200).send(JSON.stringify({ result: 'Success' })); + return res.status(200).send(JSON.stringify({ result: 'Success' })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; @@ -924,21 +1209,21 @@ const checkSavedApartment = (): RequestHandler => async (req, res) => { if (!userRef) { throw new Error('User data not found'); } - await db.runTransaction(async (t) => { + + const isSaved = await db.runTransaction(async (t) => { const userDoc = await t.get(userRef); if (!userDoc.exists) { t.set(userRef, { apartments: [] }); + return false; } const userApartments = userDoc.data()?.apartments || []; - if (userApartments.includes(apartmentId)) { - res.status(200).send(JSON.stringify({ result: true })); - } else { - res.status(200).send(JSON.stringify({ result: false })); - } + return userApartments.includes(apartmentId); }); + + return res.status(200).send(JSON.stringify({ result: isSaved })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; @@ -953,21 +1238,21 @@ const checkSavedLandlord = (): RequestHandler => async (req, res) => { if (!userRef) { throw new Error('User data not found'); } - await db.runTransaction(async (t) => { + + const isSaved = await db.runTransaction(async (t) => { const userDoc = await t.get(userRef); if (!userDoc.exists) { t.set(userRef, { landlords: [] }); + return false; } const userLandlords = userDoc.data()?.landlords || []; - if (userLandlords.includes(landlordId)) { - res.status(200).send(JSON.stringify({ result: true })); - } else { - res.status(200).send(JSON.stringify({ result: false })); - } + return userLandlords.includes(landlordId); }); + + return res.status(200).send(JSON.stringify({ result: isSaved })); } catch (err) { console.error(err); - res.status(400).send('Error'); + return res.status(400).send('Error'); } }; diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 20463657..acd849ff 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -33,10 +33,27 @@ export type Review = { readonly reports?: readonly ReportEntry[]; }; +export type BlogPost = { + readonly content: string; + readonly blurb: string; + readonly date: Date; + readonly likes?: number; + readonly tags: string[]; + readonly title: string; + readonly userId?: string | null; + readonly visibility: string; + readonly saves: number; + readonly coverImageUrl: string; +}; + export type ReviewWithId = Review & Id; +export type BlogPostWithId = BlogPost & Id; + export type ReviewInternal = Review & {}; +export type BlogPostInternal = BlogPost & {}; + export type Landlord = { readonly name: string; readonly contact: string | null; diff --git a/frontend/.eslintrc b/frontend/.eslintrc index c3647487..ddc95ee7 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -1,7 +1,14 @@ { "extends": [ - "../.eslintrc", "react-app", "react-app/jest" - ] + ], + "settings": { + "import/resolver": { + "node": { + "extensions": [".mjs", ".js", ".json", ".ts", ".tsx"], + "paths": ["common", "frontend/src", "backend/src"] + } + } + } } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 6c3bf983..d8a710a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@tinymce/tinymce-react": "3.13.1", "@types/jest": "^26.0.15", "@types/node": "^12.0.0", "@types/react": "^16.9.53", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66dd0aa0..fe54d1ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,8 @@ import React, { ReactElement, useEffect, useState } from 'react'; import './App.scss'; import { Route, Switch, useLocation } from 'react-router-dom'; import HomePage from './pages/HomePage'; -import FAQPage from './pages/FAQPage'; +import BlogPostPage from './pages/BlogPostPage'; +import BlogPostDetailPage from './pages/BlogPostDetailPage'; import ReviewPage from './pages/ReviewPage'; import LandlordPage from './pages/LandlordPage'; import ProfilePage from './pages/ProfilePage'; @@ -73,9 +74,9 @@ const home: NavbarButton = { href: '/', }; -const faq: NavbarButton = { - label: 'FAQ', - href: '/faq', +const blogs: NavbarButton = { + label: 'Advice', + href: '/blogs', }; export type CardData = { @@ -91,7 +92,7 @@ export type LocationCardData = { location: string; }; -const headersData = [home, faq]; +const headersData = [home, blogs]; hotjar.initialize(HJID, HJSV); @@ -113,7 +114,10 @@ const App = (): ReactElement => { } /> - + + + + { {isAdmin(user) && } - {pathname !== '/faq' &&