diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..28509462 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -14,6 +14,15 @@ class Bloom: content: str sent_timestamp: datetime.datetime +@dataclass +class Rebloom: + id: int + resender: User + sender: User + content: str + sent_timestamp: datetime.datetime + rebloomed: int + def add_bloom(*, sender: User, content: str) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] @@ -36,6 +45,26 @@ def add_bloom(*, sender: User, content: str) -> Bloom: dict(hashtag=hashtag, bloom_id=bloom_id), ) +def rebloom(*, rebloom_id: int, resender: User, sender: User, content: str, sent_timestamp: datetime.datetime) -> Rebloom: + with db_cursor() as cur: + cur.execute( + "INSERT INTO reblooms (id, resender_name, original_sender_name, content, send_timestamp, times_rebloomed) VALUES (%(rebloom_id)s, %(resender_name)s, %(sender_name)s, %(content)s, %(timestamp)s, 1) ON CONFLICT (id) DO UPDATE SET times_rebloomed = reblooms.times_rebloomed + 1", + dict( + rebloom_id=rebloom_id, + resender_name=resender.username, + sender_name=sender, + content=content, + timestamp=sent_timestamp + ), + ) + return Rebloom( + id=rebloom_id, + resender=resender, + sender=sender, + content=content, + sent_timestamp=sent_timestamp, + rebloomed=1 + ) def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None @@ -50,8 +79,6 @@ def get_blooms_for_user( else: before_clause = "" - limit_clause = make_limit_clause(limit, kwargs) - cur.execute( f"""SELECT blooms.id, users.username, content, send_timestamp @@ -60,8 +87,6 @@ def get_blooms_for_user( WHERE username = %(sender_username)s {before_clause} - ORDER BY send_timestamp DESC - {limit_clause} """, kwargs, ) @@ -80,6 +105,35 @@ def get_blooms_for_user( return blooms +def get_reblooms_for_user(username: str) -> List[Rebloom]: + with db_cursor() as cur: + cur.execute( + """SELECT + reblooms.id, users.username, original_sender_name, content, send_timestamp, times_rebloomed + FROM + reblooms INNER JOIN users ON users.username = reblooms.resender_name + WHERE + users.username = %(username)s + """, + {"username": username}, + ) + rows = cur.fetchall() + reblooms = [] + for row in rows: + rebloom_id, resender_name, sender_name, content, timestamp, times_rebloomed = row + reblooms.append( + Rebloom( + id=rebloom_id, + resender=resender_name, + sender=sender_name, + content=content, + sent_timestamp=timestamp, + rebloomed=times_rebloomed + ) + ) + return reblooms + + def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( @@ -140,3 +194,4 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: else: limit_clause = "" return limit_clause + diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..f1dea99e 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -111,11 +111,15 @@ def other_profile(profile_username): followers = get_inverse_followed_usernames(profile_user) all_blooms = blooms.get_blooms_for_user(profile_username) - all_blooms.reverse() + all_reblooms = blooms.get_reblooms_for_user(profile_username) + + bloom_data = all_blooms + all_reblooms + bloom_data.reverse() + return jsonify( { "username": profile_username, - "recent_blooms": all_blooms[:10], + "recent_blooms": bloom_data[:10], "follows": get_followed_usernames(profile_user), "followers": list(followers), "is_following": current_user is not None @@ -167,6 +171,26 @@ def send_bloom(): ) +@jwt_required() +def send_rebloom(): + type_check_error = verify_request_fields({"content": str}) + if type_check_error is not None: + return type_check_error + + user = get_current_user() + + if user.username == request.json["sender"]: + return make_response((f"Cannot rebloom own bloom", 422)) + + blooms.rebloom(rebloom_id=request.json["id"], resender=user, sender=request.json["sender"], content=request.json["content"], sent_timestamp=request.json["sent_timestamp"]) + + return jsonify( + { + "success": True, + } + ) + + def get_bloom(id_str): try: id_int = int(id_str) @@ -212,6 +236,12 @@ def user_blooms(profile_username): return jsonify(user_blooms) +def user_reblooms(profile_username): + user_reblooms = blooms.get_reblooms_for_user(profile_username) + user_reblooms.reverse() + return jsonify(user_reblooms) + + @jwt_required() def suggested_follows(limit_str): try: diff --git a/backend/main.py b/backend/main.py index 7ba155fa..62194586 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,8 +12,10 @@ register, self_profile, send_bloom, + send_rebloom, suggested_follows, user_blooms, + user_reblooms ) from dotenv import load_dotenv @@ -59,6 +61,8 @@ def main(): app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) + app.add_url_rule("/rebloom", methods=["POST"], view_func=send_rebloom) + app.add_url_rule("/reblooms/", view_func=user_reblooms) app.add_url_rule("/hashtag/", view_func=hashtag) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..5b598cd3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -13,6 +13,15 @@ CREATE TABLE blooms ( send_timestamp TIMESTAMP NOT NULL ); +CREATE TABLE reblooms ( + id BIGSERIAL NOT NULL PRIMARY KEY, + resender_name TEXT NOT NULL, + original_sender_name TEXT NOT NULL, + content TEXT NOT NULL, + send_timestamp TIMESTAMP NOT NULL, + times_rebloomed INT NOT NULL +); + CREATE TABLE follows ( id SERIAL PRIMARY KEY, follower INT NOT NULL REFERENCES users(id), diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..8508a70d 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,6 @@ +import { apiService } from "../lib/api.mjs" +import { state } from "../lib/state.mjs" + /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -20,6 +23,8 @@ 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 rebloomButton = bloomFrag.querySelector("[data-rebloom]") + const timesRebloomedCounter = bloomFrag.querySelector("[data-times-rebloomed]") bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -30,6 +35,16 @@ const createBloom = (template, bloom) => { ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") .body.childNodes ); + rebloomButton.hidden = state.currentUser === bloom.sender + rebloomButton.addEventListener("click", async () => { + try { + await apiService.postRebloom(bloom.id, bloom.sender, bloom.content, bloom.sent_timestamp); + } catch (error) { + throw error; + } + }); + timesRebloomedCounter.hidden = !bloom.rebloomed; + timesRebloomedCounter.textContent = `Times rebloomed: ${bloom.rebloomed}` return bloomFrag; }; diff --git a/front-end/components/error.mjs b/front-end/components/error.mjs index 3fc9eeee..80ef1946 100644 --- a/front-end/components/error.mjs +++ b/front-end/components/error.mjs @@ -7,6 +7,7 @@ const _STATUS_MESSAGES = { 404: "Not Found - The requested resource does not exist.", 405: "Not Allowed - The server knows the request method, but the target resource doesn't support this method.", 418: "I'm a teapot - Server refuses to brew coffee with a teapot.", + 422: "Invalid data - The request was well-formed but was unable to be followed due to semantic errors.", 500: "Internal Server Error - Something went wrong on the server.", }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..dca15e76 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -239,6 +239,8 @@

Share a Bloom

+

+ diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..ca26426a 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,25 @@ async function postBloom(content) { } } +async function postRebloom(id, sender, content, sent_timestamp) { + try { + const data = await _apiRequest("/rebloom", { + method: "POST", + body: JSON.stringify({id, sender, content, sent_timestamp}), + }); + + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + + return data; + } catch (error) { + // Error already handled by _apiRequest + return {success: false}; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -291,6 +310,7 @@ const apiService = { getBloom, getBlooms, postBloom, + postRebloom, getBloomsByHashtag, // User methods