diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..e1abd6aa 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -5,6 +5,7 @@ from data.connection import db_cursor from data.users import User +from psycopg2.errors import UniqueViolation @dataclass @@ -13,6 +14,8 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + re_bloom_count: int = 0 + re_bloomed_by: Optional[str] = None def add_bloom(*, sender: User, content: str) -> Bloom: @@ -54,7 +57,8 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, + (SELECT COUNT(*) FROM re_blooms WHERE re_blooms.bloom_id = blooms.id) AS re_bloom_count FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -68,33 +72,48 @@ 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, re_bloom_count = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + re_bloom_count=re_bloom_count, ) ) - return blooms + re_blooms_list = get_re_blooms_for_user(username, limit=limit) + combined = blooms + re_blooms_list + combined.sort(key=lambda b: b.sent_timestamp, reverse=True) + if limit is not None: + combined = combined[:limit] + + + return combined 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, + (SELECT COUNT(*) FROM re_blooms WHERE re_blooms.bloom_id = blooms.id) AS re_bloom_count + 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, re_bloom_count = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + re_bloom_count=re_bloom_count, ) @@ -108,7 +127,8 @@ 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, + (SELECT COUNT(*) FROM re_blooms WHERE re_blooms.bloom_id = blooms.id) AS re_bloom_count FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -121,13 +141,14 @@ 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, re_bloom_count = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + re_bloom_count=re_bloom_count, ) ) return blooms @@ -140,3 +161,66 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: else: limit_clause = "" return limit_clause + + +def re_bloom(bloom_id: int, re_bloomer: User) -> bool: + + bloom = get_bloom(bloom_id) + + if bloom is None: + return False + + now = datetime.datetime.now(tz=datetime.UTC) + + with db_cursor() as cur: + try: + cur.execute( + "INSERT INTO re_blooms (bloom_id, re_bloomer_id, re_bloom_timestamp) VALUES (%(bloom_id)s, %(re_bloomer_id)s, %(timestamp)s)", + dict( + bloom_id=bloom_id, + re_bloomer_id=re_bloomer.id, + timestamp=now, + ), + ) + + except UniqueViolation: + return False + + return True; + + +def get_re_blooms_for_user(username: str, *, limit: Optional[int] = None) -> List[Bloom]: + kwargs = {"re_bloomer_username": username} + limit_clause = make_limit_clause(limit, kwargs) + with db_cursor() as cur: + cur.execute( + f"""SELECT + blooms.id, original_sender.username, blooms.content, re_blooms.re_bloom_timestamp, + (SELECT COUNT(*) FROM re_blooms WHERE re_blooms.bloom_id = blooms.id) AS re_bloom_count + FROM + re_blooms + INNER JOIN blooms ON blooms.id = re_blooms.bloom_id + INNER JOIN users AS original_sender ON original_sender.id = blooms.sender_id + INNER JOIN users AS re_bloomer ON re_bloomer.id = re_blooms.re_bloomer_id + WHERE + re_bloomer.username = %(re_bloomer_username)s + ORDER BY re_blooms.re_bloom_timestamp DESC + {limit_clause} + """, + kwargs, + ) + rows = cur.fetchall() + re_blooms_list = [] + for row in rows: + bloom_id, sender_username, content, re_bloom_timestamp, re_bloom_count = row + re_blooms_list.append( + Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=re_bloom_timestamp, + re_bloom_count=re_bloom_count, + re_bloomed_by=username, + ) + ) + return re_blooms_list diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..82d5db31 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -245,3 +245,22 @@ def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, No ) ) return None + + +@jwt_required() +def re_bloom_bloom(id_str): + try: + bloom_id = int(id_str) + except ValueError: + return make_response(("Invalid bloom id", 400)) + + user = get_current_user() + bloom = blooms.get_bloom(bloom_id) + if bloom is None: + return make_response(("Bloom not found", 404)) + + blooms.re_bloom(bloom_id, user) + return jsonify({ + "success": True, + "message": f"Bloom {bloom_id} re-bloomed successfully" + }) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 7ba155fa..23a23806 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ send_bloom, suggested_follows, user_blooms, + re_bloom_bloom, ) from dotenv import load_dotenv @@ -60,6 +61,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("/bloom//re-bloom", methods=["POST"], view_func=re_bloom_bloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..3353cf32 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -26,3 +26,12 @@ CREATE TABLE hashtags ( bloom_id BIGINT NOT NULL REFERENCES blooms(id), UNIQUE(hashtag, bloom_id) ); + +CREATE TABLE re_blooms ( + id SERIAL PRIMARY KEY, + bloom_id BIGINT NOT NULL REFERENCES blooms(id), + re_bloomer_id INT NOT NULL REFERENCES users(id), + re_bloom_timestamp TIMESTAMP NOT NULL, + UNIQUE(bloom_id, re_bloomer_id) +) + diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..eebf2cba 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -10,6 +10,10 @@ * "sent_timestamp": "datetime as ISO 8601 formatted string"} */ + +import {apiService} from "../index.mjs" + + const createBloom = (template, bloom) => { if (!bloom) return; const bloomFrag = document.getElementById(template).content.cloneNode(true); @@ -20,6 +24,10 @@ 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 reBloomedByBanner = bloomFrag.querySelector("[data-rebloomed-by]"); + const reBloomedByLink = bloomFrag.querySelector("[data-rebloomed-by-link]"); + const reBloomBtn = bloomFrag.querySelector("[data-rebloom-btn]"); + const reBloomCount = bloomFrag.querySelector("[data-rebloom-count]"); bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -31,6 +39,21 @@ const createBloom = (template, bloom) => { .body.childNodes ); + if (bloom.re_bloomed_by) { + reBloomedByBanner.hidden = false; + reBloomedByLink.textContent = bloom.re_bloomed_by; + reBloomedByLink.setAttribute("href", `/profile/${bloom.re_bloomed_by}`); + } + + if (bloom.re_bloom_count > 0) { + reBloomCount.hidden = false; + reBloomCount.textContent = `${bloom.re_bloom_count} re-blooms`; + } + + reBloomBtn.addEventListener("click", () => { + apiService.reBloomBloom(bloom.id); + }); + return bloomFrag; }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..4fac0391 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -234,11 +234,20 @@

Share a Bloom

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..85e33865 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -281,6 +281,22 @@ async function unfollowUser(username) { } } +async function reBloomBloom(bloomId) { + try { + const data = await _apiRequest(`/bloom/${bloomId}/re-bloom`, { + method: "POST", + }); + + if (data.success) { + await getBlooms(); + } + + return data; + } catch (error) { + return {success: false}; + } +} + const apiService = { // Auth methods login, @@ -298,6 +314,7 @@ const apiService = { followUser, unfollowUser, getWhoToFollow, + reBloomBloom, }; export {apiService};