diff --git a/backend/README.md b/backend/README.md index 8e31e11..fe0b65a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -15,7 +15,7 @@ To run: 5. Run the database: `../db/run.sh` (you must have Docker installed and running). 6. Create the database schema: `../db/create-schema.sh` -You may want to run `python3 populate.py` to populate sample data. +You may want to run `python3 populate.py` to populate sample data.  If you ever need to wipe the database, just delete `../db/pg_data` (and remember to set it up again after). diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..04bd140 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,21 +13,28 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + reblooms: int + original_bloom_id: int -def add_bloom(*, sender: User, content: str) -> Bloom: +def add_bloom( + *, sender: User, content: str, original_bloom_id: Optional[int] = None +) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) + print(original_bloom_id) with db_cursor() as cur: cur.execute( - "INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)", + "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s,%(original_bloom_id)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, timestamp=datetime.datetime.now(datetime.UTC), + reblooms=0, + original_bloom_id=original_bloom_id, ), ) for hashtag in hashtags: @@ -54,7 +61,7 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -68,13 +75,22 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) ) return blooms @@ -83,18 +99,20 @@ def get_blooms_for_user( def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( - "SELECT blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", + "SELECT blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, reblooms, original_bloom_id = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) @@ -108,7 +126,7 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -121,18 +139,44 @@ def get_blooms_with_hashtag( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) ) return blooms +def update_rebloom_counter(bloom_id: int) -> None: + with db_cursor() as cur: + cur.execute( + "UPDATE blooms SET reblooms = reblooms + 1 WHERE blooms.id = %s", + (bloom_id,), + ) + + +def add_rebloom(*, sender: User, id: int) -> None: + original_bloom = get_bloom(id) + if not original_bloom: + return None + content = original_bloom.content + update_rebloom_counter(id) + add_bloom(sender=sender, content=content, original_bloom_id=id) + + def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: if limit is not None: limit_clause = "LIMIT %(limit)s" diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..a3c0426 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -212,6 +212,36 @@ def user_blooms(profile_username): return jsonify(user_blooms) +def update_rebloom_counter(bloom_id): + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.update_rebloom_counter(id_int) + return jsonify( + { + "success": True, + } + ) + + +@jwt_required() +def send_rebloom(): + user = get_current_user() + bloom_id = request.json["id"] + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.add_rebloom(sender=user, id=id_int) + + return jsonify( + { + "success": True, + } + ) + + @jwt_required() def suggested_follows(limit_str): try: diff --git a/backend/main.py b/backend/main.py index 7ba155f..d3609cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,8 @@ send_bloom, suggested_follows, user_blooms, + update_rebloom_counter, + send_rebloom, ) from dotenv import load_dotenv @@ -60,6 +62,7 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + app.add_url_rule("/rebloom", methods=["POST"], view_func=send_rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580..4b743fb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,7 +10,9 @@ CREATE TABLE blooms ( id BIGSERIAL NOT NULL PRIMARY KEY, sender_id INT NOT NULL REFERENCES users(id), content TEXT NOT NULL, - send_timestamp TIMESTAMP NOT NULL + send_timestamp TIMESTAMP NOT NULL, + reblooms INT NOT NULL DEFAULT 0, + original_bloom_id BIGINT REFERENCES blooms(id) ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..9f76dc6 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,5 @@ +import { apiService } from "../index.mjs"; + /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -7,7 +9,9 @@ * {"id": Number, * "sender": username, * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} + * "sent_timestamp": "datetime as ISO 8601 formatted string"}, + * "reblooms": "reblooms count", + * "original_bloom_id": "id of the rebloomed post" */ const createBloom = (template, bloom) => { @@ -20,8 +24,12 @@ const createBloom = (template, bloom) => { const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); + const rebloomButtonEl = bloomFrag.querySelector( + "[data-action='share-bloom']" + ); + const rebloomCountEl = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomInfoEl = bloomFrag.querySelector("[data-rebloom-info]"); - bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); @@ -30,6 +38,23 @@ const createBloom = (template, bloom) => { ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") .body.childNodes ); + // redo to "bloom.reblooms || 0" once reblooms implemented to object + rebloomCountEl.textContent = `Rebloomed ${bloom.reblooms} times`; + rebloomCountEl.hidden = bloom.reblooms == 0; + rebloomButtonEl.setAttribute("data-id", bloom.id || ""); + rebloomButtonEl.addEventListener("click", handleRebloom); + rebloomInfoEl.hidden = bloom.original_bloom_id === null; + + if (bloom.original_bloom_id !== null) { + apiService + // I had to write another fetch, because getBloom update state, which is causing recursion if I use it here + .fetchBloomData(bloom.original_bloom_id) + .then((originalBloom) => { + const timeStamp = _formatTimestamp(originalBloom.sent_timestamp); + //I used inner html to render the arrow ↪ sign + rebloomInfoEl.innerHTML = `↪ Rebloom of the ${originalBloom.sender}'s post, posted ${timeStamp} ago`; + }); + } return bloomFrag; }; @@ -84,4 +109,13 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +async function handleRebloom(event) { + const button = event.target; + const id = button.getAttribute("data-id"); + if (!id) return; + + // await apiService.updateRebloomCounter(id); + await apiService.postRebloom(id); +} + +export { createBloom, handleRebloom }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..bfae7df 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,204 @@ - - - - Purple Forest - - - -
-

- Purple Forest - PurpleForest -

-
- -
-
-
- -
-
-
-
-
-

This Legacy Code project is coursework from Code Your Future

-
-
- - - - - - - - + + + + + + + + - + + - - - + \ No newline at end of file diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..48eb9fe 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -1,5 +1,5 @@ -import {state} from "../index.mjs"; -import {handleErrorDialog} from "../components/error.mjs"; +import { state } from "../index.mjs"; +import { handleErrorDialog } from "../components/error.mjs"; // === ABOUT THE STATE // state gives you these two functions only @@ -20,13 +20,13 @@ async function _apiRequest(endpoint, options = {}) { const defaultOptions = { headers: { "Content-Type": "application/json", - ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, mode: "cors", credentials: "include", }; - const fetchOptions = {...defaultOptions, ...options}; + const fetchOptions = { ...defaultOptions, ...options }; const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; try { @@ -54,7 +54,7 @@ async function _apiRequest(endpoint, options = {}) { const contentType = response.headers.get("content-type"); return contentType?.includes("application/json") ? await response.json() - : {success: true}; + : { success: true }; } catch (error) { if (!error.status) { // Only handle network errors here, response errors are handled above @@ -70,11 +70,11 @@ function _updateProfile(username, profileData) { const index = profiles.findIndex((p) => p.username === username); if (index !== -1) { - profiles[index] = {...profiles[index], ...profileData}; + profiles[index] = { ...profiles[index], ...profileData }; } else { - profiles.push({username, ...profileData}); + profiles.push({ username, ...profileData }); } - state.updateState({profiles}); + state.updateState({ profiles }); } // ====== AUTH methods @@ -82,7 +82,7 @@ async function login(username, password) { try { const data = await _apiRequest("/login", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -96,7 +96,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +104,12 @@ async function getWhoToFollow() { try { const usernamesToFollow = await _apiRequest("/suggested-follows/3"); - state.updateState({whoToFollow: usernamesToFollow}); + state.updateState({ whoToFollow: usernamesToFollow }); return usernamesToFollow; } catch (error) { // Error already handled by _apiRequest - state.updateState({usernamesToFollow: []}); + state.updateState({ usernamesToFollow: [] }); return []; } } @@ -118,7 +118,7 @@ async function signup(username, password) { try { const data = await _apiRequest("/register", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -132,20 +132,27 @@ async function signup(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } function logout() { state.destroyState(); - return {success: true}; + return { success: true }; } // ===== BLOOM methods async function getBloom(bloomId) { const endpoint = `/bloom/${bloomId}`; const bloom = await _apiRequest(endpoint); - state.updateState({singleBloomToShow: bloom}); + state.updateState({ singleBloomToShow: bloom }); + return bloom; +} + +//this function doesn't update state as getBloom does +async function fetchBloomData(bloomId) { + const endpoint = `/bloom/${bloomId}`; + const bloom = await _apiRequest(endpoint); return bloom; } @@ -156,18 +163,18 @@ async function getBlooms(username) { const blooms = await _apiRequest(endpoint); if (username) { - _updateProfile(username, {blooms}); + _updateProfile(username, { blooms }); } else { - state.updateState({timelineBlooms: blooms}); + state.updateState({ timelineBlooms: blooms }); } return blooms; } catch (error) { // Error already handled by _apiRequest if (username) { - _updateProfile(username, {blooms: []}); + _updateProfile(username, { blooms: [] }); } else { - state.updateState({timelineBlooms: []}); + state.updateState({ timelineBlooms: [] }); } return []; } @@ -189,7 +196,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +204,7 @@ async function postBloom(content) { try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -208,10 +215,23 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } +async function postRebloom(originalId) { + try { + const data = await _apiRequest(`/rebloom`, { + method: "POST", + body: JSON.stringify({ id: originalId }), + }); + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + } catch (error) {} +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -225,16 +245,16 @@ async function getProfile(username) { const currentUsername = profileData.username; const fullProfileData = await _apiRequest(`/profile/${currentUsername}`); _updateProfile(currentUsername, fullProfileData); - state.updateState({currentUser: currentUsername, isLoggedIn: true}); + state.updateState({ currentUser: currentUsername, isLoggedIn: true }); } return profileData; } catch (error) { // Error already handled by _apiRequest if (!username) { - state.updateState({isLoggedIn: false, currentUser: null}); + state.updateState({ isLoggedIn: false, currentUser: null }); } - return {success: false}; + return { success: false }; } } @@ -242,7 +262,7 @@ async function followUser(username) { try { const data = await _apiRequest("/follow", { method: "POST", - body: JSON.stringify({follow_username: username}), + body: JSON.stringify({ follow_username: username }), }); if (data.success) { @@ -255,7 +275,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +297,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -289,9 +309,11 @@ const apiService = { // Bloom methods getBloom, + fetchBloomData, getBlooms, postBloom, getBloomsByHashtag, + postRebloom, // User methods getProfile, @@ -300,4 +322,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/tests/home.spec.mjs b/front-end/tests/home.spec.mjs index 0c9a4d4..86d84d8 100644 --- a/front-end/tests/home.spec.mjs +++ b/front-end/tests/home.spec.mjs @@ -1,8 +1,14 @@ -import {test, expect} from "@playwright/test"; -import {TIMELINE_USERNAMES_ELEMENTS_LOCATOR, loginAsSample, postBloom, logout, waitForLocatorToHaveMatches} from "./test-utils.mjs"; +import { test, expect } from "@playwright/test"; +import { + TIMELINE_USERNAMES_ELEMENTS_LOCATOR, + loginAsSample, + postBloom, + logout, + waitForLocatorToHaveMatches, +} from "./test-utils.mjs"; test.describe("Home View", () => { - test("shows login component when not logged in", async ({page}) => { + test("shows login component when not logged in", async ({ page }) => { // Given an index load await page.goto("/"); @@ -19,7 +25,7 @@ test.describe("Home View", () => { ).toBeVisible(); }); - test("shows core home components when logged in", async ({page}) => { + test("shows core home components when logged in", async ({ page }) => { // Given I am logged in await loginAsSample(page); @@ -31,18 +37,19 @@ test.describe("Home View", () => { await expect(page.locator('[data-form="bloom"]')).toBeVisible(); }); - test("shows timeline after creating a bloom", async ({page}) => { + test("shows timeline after creating a bloom", async ({ page }) => { // Given I am logged in await loginAsSample(page); - // When I create a bloom await postBloom(page, "My first bloom!"); // Then I see the bloom in the timeline - await expect(page.locator("[data-bloom] [data-content]").first()).toContainText("My first bloom!"); + await expect( + page.locator("[data-bloom] [data-content]").first() + ).toContainText("My first bloom!"); }); - test("hides components after logout", async ({page}) => { + test("hides components after logout", async ({ page }) => { // Given I am logged in await loginAsSample(page); @@ -59,12 +66,19 @@ test.describe("Home View", () => { await expect(page.locator('[data-form="bloom"]')).not.toBeAttached(); }); - test("shows own and followed user's posts in home timeline", async ({page}) => { + test("shows own and followed user's posts in home timeline", async ({ + page, + }) => { // Given I am logged in as sample who already follows JustSomeGuy await loginAsSample(page); - await waitForLocatorToHaveMatches(page, TIMELINE_USERNAMES_ELEMENTS_LOCATOR); - const postUsernames = await page.locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR).allInnerTexts(); + await waitForLocatorToHaveMatches( + page, + TIMELINE_USERNAMES_ELEMENTS_LOCATOR + ); + const postUsernames = await page + .locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR) + .allInnerTexts(); // Then I see my own posts in my timeline expect(postUsernames).toContain("sample"); // And I see my a followed user's posts in my timeline diff --git a/front-end/tests/rebloom.spec.mjs b/front-end/tests/rebloom.spec.mjs new file mode 100644 index 0000000..fc7d42e --- /dev/null +++ b/front-end/tests/rebloom.spec.mjs @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; +import { + TIMELINE_USERNAMES_ELEMENTS_LOCATOR, + loginAsSample, + loginAsJustSomeGuy, + loginAsSwiz, + logout, + postBloom, +} from "./test-utils.mjs"; + +test.describe("Rebloom functionality", () => { + test("'Share' button is visible", async ({ page }) => { + // Given I am logged in as sample + await loginAsSample(page); + // When I go to AS profile + await page.goto("/#/profile/AS"); + // Then I see a "Share "button + const shareButton = page.locator('[data-action="share-bloom"]'); + await expect(shareButton).toBeVisible(); + }); + + test("Follower see your new post in their's home view", async ({ page }) => { + // Given I am logged in as sample + await loginAsSample(page); + + // When I create a bloom + await postBloom(page, "My 666 bloom!"); + + // Then I see the bloom in the timeline + await expect( + page.locator("[data-bloom] [data-content]").first() + ).toContainText("My 666 bloom!"); + + // When I logout + await logout(page); + + // When I am logged in as Swiz who already follows Sample + await loginAsSwiz(page); + + // Then I see the sample's bloom in the timeline + await expect( + page.locator("[data-bloom] [data-content]").first() + ).toContainText("My 666 bloom!"); + }); + + test("Rebloom count updates, when click share", async ({ page }) => { + // Given I am logged in as sample + await loginAsSample(page); + + await postBloom(page, "My 666 bloom!"); + + await logout(page); + + // When I am logged in as Swiz who already follows Sample + await loginAsSwiz(page); + + await page.click(`[data-action="share-bloom"]`); + await page.waitForTimeout(200); + const rebloomCount = page.locator("[data-rebloom-count]").nth(1); + await page.waitForTimeout(200); + await expect(rebloomCount).toHaveText("Rebloomed 1 times"); + }); + + test("New rebloom is marked as a rebloom correctly", async ({ page }) => { + await loginAsSample(page); + + await postBloom(page, "My 666 bloom!"); + + await logout(page); + + // When I am logged in as Swiz who already follows Sample + await loginAsSwiz(page); + + await page.click(`[data-action="share-bloom"]`); + const rebloomInfo = page.locator("[data-rebloom-info]").first(); + await expect(rebloomInfo).toBeVisible(); + await expect(rebloomInfo).toHaveText( + "↪ Rebloom of the sample's post, posted 1h ago" + ); + }); +}); diff --git a/front-end/tests/test-utils.mjs b/front-end/tests/test-utils.mjs index 5651a15..e9edcd7 100644 --- a/front-end/tests/test-utils.mjs +++ b/front-end/tests/test-utils.mjs @@ -2,7 +2,7 @@ * Common test actions for Playwright tests */ -import {expect} from "@playwright/test"; +import { expect } from "@playwright/test"; /** * Log in with sample credentials @@ -26,6 +26,20 @@ export async function loginAsJustSomeGuy(page) { await page.click('[data-form="login"] [data-submit]'); } +/** + * Log in with sample credentials + * @param {import('@playwright/test').Page} page + */ +export async function loginAsSwiz(page) { + await page.goto("/"); + await page.fill('[data-form="login"] input[name="username"]', "Swiz"); + await page.fill( + '[data-form="login"] input[name="password"]', + "singingalldayeveryday" + ); + await page.click('[data-form="login"] [data-submit]'); +} + /** * Sign up with generated credentials * @param {import('@playwright/test').Page} page @@ -48,7 +62,10 @@ export async function signUp(page, username) { * @param {string} content - Bloom content */ export async function postBloom(page, content) { + // Added timeouts here because tests try to fill textarea before the whole page is loaded + await page.waitForTimeout(400); await page.fill('[data-form="bloom"] textarea[name="content"]', content); + await page.waitForTimeout(200); await page.click('[data-form="bloom"] [data-submit]'); } @@ -68,6 +85,8 @@ export function generateUsername() { return `testuser_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; } -export const TIMELINE_USERNAMES_ELEMENTS_LOCATOR = "#timeline-container article [data-username]"; +export const TIMELINE_USERNAMES_ELEMENTS_LOCATOR = + "#timeline-container article [data-username]"; -export const waitForLocatorToHaveMatches = async (page, locator) => await expect.poll(() => page.locator(locator).count()).toBeGreaterThan(0); +export const waitForLocatorToHaveMatches = async (page, locator) => + await expect.poll(() => page.locator(locator).count()).toBeGreaterThan(0);