diff --git a/backend/database/models/source.py b/backend/database/models/source.py index f42ebcd62..54bb8d79c 100644 --- a/backend/database/models/source.py +++ b/backend/database/models/source.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import logging +from collections import OrderedDict from backend.schemas import ( JsonSerializable, PropertyEnum, NodeConflictException) from backend.database.models.contact import SocialMediaContact, EmailContact @@ -353,12 +354,126 @@ def __repr__(self): """Represent instance as a unique string.""" return f"" + def get_activity_summary(self, top_locations: int = 5) -> dict: + """Aggregate contribution history and changed-record locations.""" + def month_start_offset(base: datetime, offset: int) -> datetime: + month_index = (base.year * 12 + (base.month - 1)) + offset + year = month_index // 12 + month = month_index % 12 + 1 + return datetime(year, month, 1) + + time_query = """ + WITH date.truncate('month', date()) AS current_month_start + WITH current_month_start, + current_month_start + duration('P1M') AS next_month_start, + current_month_start - duration('P11M') AS start_of_window + MATCH (s:Source {uid: $uid})<-[:ATTRIBUTED_TO]-(c:Change) + WHERE date(c.timestamp) >= start_of_window + AND date(c.timestamp) < next_month_start + WITH date.truncate('month', date(c.timestamp)) AS bucket, + count(*) AS count + RETURN toString(bucket) AS date, count + ORDER BY bucket ASC + """ + rows, _ = db.cypher_query(time_query, {"uid": self.uid}) + + counts_by_month = {row[0]: row[1] for row in rows} + current_month_start = datetime( + datetime.utcnow().year, + datetime.utcnow().month, + 1, + ) + contributions_over_time = [] + for offset in range(-11, 1): + bucket = month_start_offset(current_month_start, offset) + bucket_key = bucket.date().isoformat() + contributions_over_time.append({ + "date": bucket_key, + "count": counts_by_month.get(bucket_key, 0) + }) + + last_active_at = None + last_active_query = """ + MATCH (s:Source {uid: $uid})<-[:ATTRIBUTED_TO]-(c:Change) + RETURN c.timestamp + ORDER BY c.timestamp DESC + LIMIT 1 + """ + last_active_rows, _ = db.cypher_query( + last_active_query, + {"uid": self.uid}, + ) + if last_active_rows: + last_active = last_active_rows[0][0] + last_active_at = ( + last_active.isoformat() + if hasattr(last_active, "isoformat") + else str(last_active) + ) + + location_query = """ + MATCH (s:Source {uid: $uid})<-[:ATTRIBUTED_TO]-(c:Change) + -[:CHANGE_TO]->(n) + WHERE NOT n:Officer + OPTIONAL MATCH (n:Complaint)-[:OCCURRED_IN]-> + (complaint_location:Location) + WITH + coalesce( + complaint_location.city, + CASE WHEN n:Agency THEN n.hq_city END, + CASE WHEN n:Unit THEN n.hq_city END + ) AS city, + coalesce( + complaint_location.state, + CASE WHEN n:Agency THEN n.hq_state END, + CASE WHEN n:Unit THEN n.hq_state END + ) AS state + WITH CASE + WHEN city IS NOT NULL AND state IS NOT NULL THEN city + ', ' + state + WHEN city IS NOT NULL THEN city + WHEN state IS NOT NULL THEN state + ELSE 'Unknown' + END AS location_label, + count(*) AS count + RETURN location_label, count + ORDER BY count DESC, location_label ASC + """ + location_rows, _ = db.cypher_query(location_query, {"uid": self.uid}) + + contribution_locations = [] + other_count = 0 + for index, row in enumerate(location_rows): + label, count = row + if index < top_locations: + contribution_locations.append({ + "label": label, + "count": count + }) + else: + other_count += count + + if other_count: + contribution_locations.append({ + "label": "Other", + "count": other_count + }) + + total_changes = sum(item["count"] for item in contributions_over_time) + + return OrderedDict({ + "last_active_at": last_active_at, + "total_changes": total_changes, + "contributions_over_time": contributions_over_time, + "contribution_locations": contribution_locations + }) + def update_source( self, *, name: str | None = None, contact_email: str | None = None, url: str | None = None, + description: str | None = None, slug: str | None = None ) -> None: """Update the source details. @@ -368,17 +483,31 @@ def update_source( contact_email (str | None): The contact email for the source. url (str | None): The website URL for the source. """ + should_save = False + if name is not None and name != self.name: self.name = name + should_save = True if url is not None and url != self.url: self.url = url + should_save = True + if description is not None and description != self.description: + self.description = description + should_save = True if contact_email is not None: email_node = EmailContact.get_or_create(contact_email) current_email = self.primary_email.single() self.primary_email.reconnect(current_email, email_node) if slug is not None: - self.set_slug(slug) - self.save() + self.slug = slugify(slug) + self.slug_generated = False + self.slug_generated_from = None + should_save = True + elif should_save and self.slug_generated: + self._auto_generate_slug() + should_save = True + if should_save: + self.save() @classmethod def create_source( @@ -387,6 +516,7 @@ def create_source( name: str, contact_email: str, url: str | None = None, + description: str | None = None, slug: str | None = None ) -> "Source": """Create a new data source. @@ -411,13 +541,19 @@ def create_source( except DoesNotExist: source = cls( name=name, - url=url - ).save() + url=url, + description=description + ) + if slug: + source.slug = slugify(slug) + source.slug_generated = False + source.slug_generated_from = None + else: + source._auto_generate_slug() + source = source.save() except Exception as e: logging.error(f"Error creating source: {e}") raise e - if slug: - source.set_slug(slug) email = EmailContact.get_or_create(contact_email) social = SocialMediaContact().save() source.primary_email.connect(email) diff --git a/backend/dto/user_profile.py b/backend/dto/user_profile.py new file mode 100644 index 000000000..e7528d76f --- /dev/null +++ b/backend/dto/user_profile.py @@ -0,0 +1,72 @@ +from pydantic import Field, field_validator + +from backend.dto.common import RequestDTO +from backend.dto.common_filters import validate_state_code + + +class UserContactInfoDTO(RequestDTO): + additional_emails: list[str] | None = None + phone_numbers: list[str] | None = None + + +class UserLocationDTO(RequestDTO): + city: str | None = None + state: str | None = None + + @field_validator("state") + def validate_state(cls, value): + return validate_state_code(value) + + +class UserEmploymentDTO(RequestDTO): + employer: str | None = None + title: str | None = None + + +class UserSocialMediaDTO(RequestDTO): + twitter_url: str | None = None + facebook_url: str | None = None + linkedin_url: str | None = None + instagram_url: str | None = None + youtube_url: str | None = None + tiktok_url: str | None = None + + +class UpdateCurrentUser(RequestDTO): + first_name: str | None = None + last_name: str | None = None + bio: str | None = None + primary_email: str | None = None + contact_info: UserContactInfoDTO | None = None + website: str | None = None + location: UserLocationDTO | None = None + employment: UserEmploymentDTO | None = None + profile_image: str | None = None + social_media: UserSocialMediaDTO | None = None + + +class GetUserParams(RequestDTO): + include: list[str] | None = Field( + None, + description="Related entities to include in the response.", + ) + + @field_validator("include") + def validate_include(cls, value): + allowed_includes = {"memberships"} + if value: + invalid = set(value) - allowed_includes + if invalid: + raise ValueError( + f"Invalid include parameters: {', '.join(sorted(invalid))}" + ) + return value + + +class UserSuggestionParams(RequestDTO): + limit: int = Field( + 4, + ge=1, + le=20, + description="Maximum number of suggested people to return.", + ) diff --git a/backend/routes/sources.py b/backend/routes/sources.py index 66f93194e..ad07812a7 100644 --- a/backend/routes/sources.py +++ b/backend/routes/sources.py @@ -80,6 +80,7 @@ def create_source(): name=body.name, contact_email=body.contact_email, url=body.url, + description=body.description, slug=body.slug) except NodeConflictException: abort(409, description="Source already exists") @@ -170,6 +171,7 @@ def update_source(source_uid: str): name=body.name, contact_email=body.contact_email, url=body.url, + description=body.description, slug=body.slug ) except Exception as e: @@ -207,6 +209,18 @@ def get_source_members(source_uid: int): return ordered_jsonify(results), 200 +@bp.route("//activity", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +def get_source_activity(source_uid: str): + """Get aggregated activity for a source.""" + p = Source.nodes.get_or_none(uid=source_uid) + if p is None: + abort(404, description="Source not found") + + return ordered_jsonify(p.get_activity_summary()), 200 + + """ This class currently doesn't work with the `source_member_to_orm` class AddMemberSchema(BaseModel): user_email: str diff --git a/backend/routes/users.py b/backend/routes/users.py index eb16bde2c..7208d48f2 100644 --- a/backend/routes/users.py +++ b/backend/routes/users.py @@ -1,253 +1,147 @@ -import logging - -from flask import Blueprint, jsonify, request, send_from_directory -from flask_cors import cross_origin -from flask_jwt_extended import jwt_required, get_jwt_identity -import os -from urllib.parse import urlparse -import uuid -from werkzeug.utils import secure_filename -import boto3 -from ..database import User, EmailContact, SocialMediaContact, PhoneContact +from flask import Blueprint, abort, request +from flask_jwt_extended import get_jwt, get_jwt_identity +from flask_jwt_extended.view_decorators import jwt_required + +from backend.auth.jwt import min_role_required +from backend.database.models.user import UserRole +from backend.dto.user_profile import ( + GetUserParams, + UpdateCurrentUser, + UserSuggestionParams, +) +from backend.schemas import ordered_jsonify, validate_request +from backend.services.user_service import UserService + bp = Blueprint("users", __name__, url_prefix="/api/v1/users") +user_service = UserService() + + +def _not_found_response(message: str): + return ordered_jsonify({"message": message}), 404 @bp.route("/self", methods=["GET"]) -@cross_origin() @jwt_required() +@min_role_required(UserRole.PUBLIC) def get_current_user(): - """Return the currently authenticated user's full profile""" - uid = get_jwt_identity() + """Return the currently authenticated user's full profile.""" + jwt_decoded = get_jwt() + raw = { + **request.args, + "include": request.args.getlist("include"), + } try: - user = User.nodes.get(uid=uid) - except User.DoesNotExist: - return jsonify({"message": "User not found"}), 404 - - primary_email = user.email - additional_emails = [e.email for e in user.secondary_emails.all()] - phone_numbers = [p.phone_number for p in user.phone_contacts.all()] - - sm = user.social_media_contacts.single() - social_media = {} - if sm: - social_media = { - "twitter_url": getattr(sm, "twitter_url", None), - "facebook_url": getattr(sm, "facebook_url", None), - "linkedin_url": getattr(sm, "linkedin_url", None), - "instagram_url": getattr(sm, "instagram_url", None), - "youtube_url": getattr(sm, "youtube_url", None), - "tiktok_url": getattr(sm, "tiktok_url", None), - } - - payload = { - "uid": user.uid, - "first_name": user.first_name, - "last_name": user.last_name, - "primary_email": primary_email, - "contact_info": { - "additional_emails": additional_emails, - "phone_numbers": phone_numbers, - }, - "website": user.website, - "location": { - "city": user.city, - "state": user.state, - }, - "employment": { - "employer": user.organization, - "title": user.title, - }, - "bio": user.biography, - "profile_image": user.profile_image, - "social_media": social_media, + params = GetUserParams(**raw) + response = user_service.get_user_profile( + jwt_decoded["sub"], + includes=params.include or [], + ) + return ordered_jsonify(response), 200 + except LookupError as e: + return _not_found_response(str(e)) + except Exception as e: + abort(400, description=str(e)) + + +@bp.route("/", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +def get_user_by_uid(user_uid: str): + """Return a user's profile by UID.""" + raw = { + **request.args, + "include": request.args.getlist("include"), } - return jsonify(payload), 200 + try: + params = GetUserParams(**raw) + response = user_service.get_user_profile( + user_uid, + includes=params.include or [], + ) + return ordered_jsonify(response), 200 + except LookupError as e: + return _not_found_response(str(e)) + except Exception as e: + abort(400, description=str(e)) -@bp.route("/self", methods=["PATCH", "OPTIONS"]) -@cross_origin() +@bp.route("/self/suggestions/people", methods=["GET"]) @jwt_required() +@min_role_required(UserRole.PUBLIC) +def get_people_suggestions(): + """Return people suggestions for the current user.""" + jwt_decoded = get_jwt() + + try: + params = UserSuggestionParams(**request.args) + response = user_service.get_people_suggestions( + jwt_decoded["sub"], + limit=params.limit, + ) + return ordered_jsonify(response), 200 + except LookupError as e: + return _not_found_response(str(e)) + except Exception as e: + abort(400, description=str(e)) + + +@bp.route("/self", methods=["PATCH"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +@validate_request(UpdateCurrentUser) def update_current_user(): - """Update current user profile""" - uid = get_jwt_identity() - user = User.nodes.get(uid=uid) - data = request.get_json() or {} - - for field in ["first_name", "last_name", "bio"]: - if field in data: - setattr(user, {"bio": "biography"}.get(field, field), data[field]) - - if "primary_email" in data: - new_email = data["primary_email"] - if new_email: - email_contact = EmailContact.get_or_create(new_email) - existing_email = user.primary_email.single() - if existing_email: - user.primary_email.reconnect(existing_email, email_contact) - else: - user.primary_email.connect(email_contact) - - contact_info = data.get("contact_info", {}) - - secondary_emails = contact_info.get("additional_emails", []) - user.secondary_emails.disconnect_all() - - for email in secondary_emails: - if email: - contact = EmailContact.get_or_create(email) - user.secondary_emails.connect(contact) - - phone_numbers = contact_info.get("phone_numbers", []) - if phone_numbers: - user.phone_contacts.disconnect_all() - for phone in phone_numbers: - if phone: - phone_contact = PhoneContact.get_or_create(phone) - user.phone_contacts.connect(phone_contact) - - if "website" in data: - user.website = data["website"] - - location = data.get("location", {}) - if location.get("city"): - user.city = location["city"] - if location.get("state"): - user.state = location["state"] - - employment = data.get("employment", {}) - if employment.get("employer"): - user.organization = employment["employer"] - if employment.get("title"): - user.title = employment["title"] - - social_media = data.get("social_media", {}) - if social_media: - existing_sm = user.social_media_contacts.single() - if existing_sm: - for k, v in social_media.items(): - if v is not None: - setattr(existing_sm, k, v) - existing_sm.save() - else: - new_sm = SocialMediaContact(**social_media).save() - user.social_media_contacts.connect(new_sm) - - profile_image = data.get("profile_image") - if profile_image: - user.profile_image = profile_image - - user.save() - - return get_current_user() + """Update the currently authenticated user's profile.""" + body: UpdateCurrentUser = request.validated_body + jwt_decoded = get_jwt() + + try: + response = user_service.update_current_user_profile( + user_uid=jwt_decoded["sub"], + body=body, + ) + return ordered_jsonify(response), 200 + except LookupError as e: + return _not_found_response(str(e)) + except ValueError as e: + abort(400, description=str(e)) @bp.route("/self/upload-profile-image", methods=["POST"]) -@cross_origin() @jwt_required() +@min_role_required(UserRole.PUBLIC) def upload_profile_image(): - """Update current user profile image""" - user_id = get_jwt_identity() - + """Update the current user's profile image.""" if "file" not in request.files: - return jsonify({"error": "Missing file"}), 400 + abort(400, description="Missing file") file = request.files["file"] - if not file or not file.filename: - return jsonify({"error": "Empty file"}), 400 + abort(400, description="Empty file") - try: - user = User.nodes.get(uid=user_id) - except User.DoesNotExist: - return jsonify({"message": "User not found"}), 404 + user_uid = get_jwt_identity() try: - url = save_profile_photo(file, user_id) + response = user_service.update_profile_image(user_uid, file) + return ordered_jsonify(response), 200 + except LookupError as e: + abort(404, description=str(e)) except ValueError as e: - return jsonify({"error": str(e)}), 400 - - # Update Neo4j - user.profile_image = url - user.save() - - return jsonify({"profile_image_url": url}), 200 - - -def save_profile_photo(file, user_id): - """ - Saves file locally or to S3 depending on environment. - Returns the URL to store in Neo4j. - """ - filename = secure_filename(file.filename) - ext = os.path.splitext(filename)[1].lower() - - if not ext: - raise ValueError("File must have an extension") - if ext not in (".jpg", ".jpeg", ".png", ".gif"): - raise ValueError(f"Invalid file type: {ext}") - - # ---------- LOCAL ---------- - if os.environ.get("FLASK_ENV") != "production": - upload_dir = os.getenv("PROFILE_PIC_FOLDER") - - os.makedirs(upload_dir, exist_ok=True) - - filename = f"user_{user_id}{ext}" - path = os.path.join(upload_dir, filename) - - file.save(path) - - return path - - # ---------- PRODUCTION (S3) ---------- - else: - s3 = boto3.client("s3") - bucket = os.getenv("S3_BUCKET") - - key = f"profile_photos/user_{user_id}_{uuid.uuid4().hex}{ext}" - - s3.upload_fileobj( - file, - bucket, - key, - ExtraArgs={ - "ContentType": file.mimetype, - }, - ) - - # S3 URI - return f"s3://{bucket}/{key}" + abort(400, description=str(e)) @bp.route("/self/profile-image", methods=["GET"]) @jwt_required() +@min_role_required(UserRole.PUBLIC) def get_profile_photo(): - user_id = get_jwt_identity() + """Retrieve the current user's profile image.""" + user_uid = get_jwt_identity() + try: - user = User.nodes.get(uid=user_id) - except User.DoesNotExist: - return {"error": "User not found"}, 404 - if not user or not user.profile_image: - return {"error": "No profile photo"}, 404 - - if os.environ.get("FLASK_ENV") != "production": - # local dev - logging.debug('retrieve image from %s', user.profile_image) - filename = os.path.basename(user.profile_image) - directory = os.getenv("PROFILE_PIC_FOLDER") - return send_from_directory(directory, filename) - else: - parsed = urlparse(user.profile_image) - bucket = parsed.netloc - key = parsed.path.lstrip("/") - - s3 = boto3.client("s3") - url = s3.generate_presigned_url( - "get_object", - Params={"Bucket": bucket, "Key": key}, - ExpiresIn=3600, # 1 hour - ) - return jsonify({"profile_image_url": url}), 200 + return user_service.get_profile_photo(user_uid) + except LookupError as e: + abort(404, description=str(e)) + except FileNotFoundError as e: + abort(404, description=str(e)) diff --git a/backend/services/user_service.py b/backend/services/user_service.py new file mode 100644 index 000000000..efddbf027 --- /dev/null +++ b/backend/services/user_service.py @@ -0,0 +1,320 @@ +import os +import uuid +from urllib.parse import urlparse + +import boto3 +from flask import jsonify, send_from_directory +from neomodel import db +from werkzeug.utils import secure_filename + +from backend.database import ( + EmailContact, + PhoneContact, + SocialMediaContact, + Source, + User, +) +from backend.dto.user_profile import UpdateCurrentUser + + +class UserService: + allowed_profile_photo_extensions = {".jpg", ".jpeg", ".png", ".gif"} + + def get_user_profile( + self, + user_uid: str, + includes: list[str] | None = None, + ) -> dict: + user = User.nodes.get_or_none(uid=user_uid) + if user is None: + raise LookupError("User not found") + return self.serialize_user_profile(user, includes=includes or []) + + def get_people_suggestions(self, user_uid: str, limit: int = 4) -> dict: + user = User.nodes.get_or_none(uid=user_uid) + if user is None: + raise LookupError("User not found") + + query = """ + MATCH (me:User {uid: $user_uid})-[my_membership:IS_MEMBER]->(s:Source) + <-[other_membership:IS_MEMBER]-(other:User) + WHERE other.uid <> $user_uid + AND coalesce(my_membership.is_active, true) = true + AND coalesce(other_membership.is_active, true) = true + WITH other, s + ORDER BY s.name ASC + WITH + other, + collect(DISTINCT s)[0..3] AS shared_sources, + count(DISTINCT s) AS shared_source_count + ORDER BY + shared_source_count DESC, + other.last_name ASC, + other.first_name ASC + LIMIT $limit + RETURN + other.uid AS uid, + other.first_name AS first_name, + other.last_name AS last_name, + other.title AS title, + other.organization AS organization, + other.profile_image AS profile_image, + shared_source_count, + [ + source IN shared_sources | + { + uid: source.uid, + slug: source.slug, + name: source.name + } + ] AS shared_sources + """ + rows, _ = db.cypher_query( + query, + {"user_uid": user_uid, "limit": limit}, + ) + + return { + "results": [ + { + "uid": row[0], + "first_name": row[1], + "last_name": row[2], + "title": row[3], + "organization": row[4], + "profile_image": row[5], + "shared_source_count": row[6], + "shared_sources": row[7], + } + for row in rows + ] + } + + def update_current_user_profile( + self, + user_uid: str, + body: UpdateCurrentUser, + ) -> dict: + user = User.nodes.get_or_none(uid=user_uid) + if user is None: + raise LookupError("User not found") + + fields_set = body.model_fields_set + + if "first_name" in fields_set: + user.first_name = body.first_name + if "last_name" in fields_set: + user.last_name = body.last_name + if "bio" in fields_set: + user.biography = body.bio + if "website" in fields_set: + user.website = body.website + if "profile_image" in fields_set: + user.profile_image = body.profile_image + + if "primary_email" in fields_set and body.primary_email: + email_contact = EmailContact.get_or_create(body.primary_email) + existing_email = user.primary_email.single() + if existing_email: + user.primary_email.reconnect(existing_email, email_contact) + else: + user.primary_email.connect(email_contact) + + if "contact_info" in fields_set and body.contact_info is not None: + contact_fields = body.contact_info.model_fields_set + + if "additional_emails" in contact_fields: + user.secondary_emails.disconnect_all() + for email in body.contact_info.additional_emails or []: + if email: + user.secondary_emails.connect( + EmailContact.get_or_create(email) + ) + + if "phone_numbers" in contact_fields: + user.phone_contacts.disconnect_all() + for phone in body.contact_info.phone_numbers or []: + if phone: + user.phone_contacts.connect( + PhoneContact.get_or_create(phone) + ) + + if "location" in fields_set and body.location is not None: + location_fields = body.location.model_fields_set + if "city" in location_fields: + user.city = body.location.city + if "state" in location_fields: + user.state = body.location.state + + if "employment" in fields_set and body.employment is not None: + employment_fields = body.employment.model_fields_set + if "employer" in employment_fields: + user.organization = body.employment.employer + if "title" in employment_fields: + user.title = body.employment.title + + if "social_media" in fields_set and body.social_media is not None: + social_media_fields = body.social_media.model_fields_set + if social_media_fields: + existing_sm = user.social_media_contacts.single() + payload = body.social_media.model_dump(exclude_unset=True) + if existing_sm: + for field, value in payload.items(): + setattr(existing_sm, field, value) + existing_sm.save() + else: + user.social_media_contacts.connect( + SocialMediaContact(**payload).save() + ) + + user.save() + return self.serialize_user_profile(user) + + def save_profile_photo(self, file, user_uid: str) -> str: + filename = secure_filename(file.filename) + ext = os.path.splitext(filename)[1].lower() + + if not ext: + raise ValueError("File must have an extension") + if ext not in self.allowed_profile_photo_extensions: + raise ValueError(f"Invalid file type: {ext}") + + if os.environ.get("FLASK_ENV") != "production": + upload_dir = os.getenv("PROFILE_PIC_FOLDER") + os.makedirs(upload_dir, exist_ok=True) + + path = os.path.join(upload_dir, f"user_{user_uid}{ext}") + file.save(path) + return path + + s3 = boto3.client("s3") + bucket = os.getenv("S3_BUCKET") + key = f"profile_photos/user_{user_uid}_{uuid.uuid4().hex}{ext}" + + s3.upload_fileobj( + file, + bucket, + key, + ExtraArgs={"ContentType": file.mimetype}, + ) + return f"s3://{bucket}/{key}" + + def update_profile_image(self, user_uid: str, file) -> dict: + user = User.nodes.get_or_none(uid=user_uid) + if user is None: + raise LookupError("User not found") + + url = self.save_profile_photo(file, user_uid) + user.profile_image = url + user.save() + + return {"profile_image_url": url} + + def get_profile_photo(self, user_uid: str): + user = User.nodes.get_or_none(uid=user_uid) + if user is None: + raise LookupError("User not found") + if not user.profile_image: + raise FileNotFoundError("No profile photo") + + if os.environ.get("FLASK_ENV") != "production": + filename = os.path.basename(user.profile_image) + directory = os.getenv("PROFILE_PIC_FOLDER") + return send_from_directory(directory, filename) + + parsed = urlparse(user.profile_image) + s3 = boto3.client("s3") + url = s3.generate_presigned_url( + "get_object", + Params={ + "Bucket": parsed.netloc, + "Key": parsed.path.lstrip("/"), + }, + ExpiresIn=3600, + ) + return jsonify({"profile_image_url": url}), 200 + + @staticmethod + def _serialize_memberships(user: User) -> list[dict]: + query = """ + MATCH (u:User {uid: $user_uid})-[membership:IS_MEMBER]->(s:Source) + RETURN + s, + membership.role AS role, + membership.date_joined AS date_joined, + membership.is_active AS is_active + ORDER BY membership.date_joined DESC, s.name ASC + """ + rows, _ = db.cypher_query(query, {"user_uid": user.uid}) + + memberships = [] + for source_node, role, date_joined, is_active in rows: + source = Source.inflate(source_node) + memberships.append({ + "source": { + "uid": source.uid, + "slug": source.slug, + "name": source.name, + "description": source.description, + "website": source.url, + }, + "role": role, + "date_joined": ( + date_joined.isoformat() + if date_joined is not None + else None + ), + "is_active": is_active, + }) + + return memberships + + @classmethod + def serialize_user_profile( + cls, + user: User, + includes: list[str] | None = None, + ) -> dict: + primary_email = user.email + additional_emails = [e.email for e in user.secondary_emails.all()] + phone_numbers = [p.phone_number for p in user.phone_contacts.all()] + + social_media = {} + sm = user.social_media_contacts.single() + if sm: + social_media = { + "twitter_url": getattr(sm, "twitter_url", None), + "facebook_url": getattr(sm, "facebook_url", None), + "linkedin_url": getattr(sm, "linkedin_url", None), + "instagram_url": getattr(sm, "instagram_url", None), + "youtube_url": getattr(sm, "youtube_url", None), + "tiktok_url": getattr(sm, "tiktok_url", None), + } + + profile = { + "uid": user.uid, + "first_name": user.first_name, + "last_name": user.last_name, + "primary_email": primary_email, + "contact_info": { + "additional_emails": additional_emails, + "phone_numbers": phone_numbers, + }, + "website": user.website, + "location": { + "city": user.city, + "state": user.state, + }, + "employment": { + "employer": user.organization, + "title": user.title, + }, + "bio": user.biography, + "profile_image": user.profile_image, + "social_media": social_media, + } + + if includes and "memberships" in includes: + profile["memberships"] = cls._serialize_memberships(user) + + return profile diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py index 09d18e4bb..6f8699348 100644 --- a/backend/tests/test_sources.py +++ b/backend/tests/test_sources.py @@ -3,7 +3,15 @@ from datetime import datetime, timedelta from flask_jwt_extended import decode_token from slugify import slugify -from backend.database import Source, MemberRole +from backend.database import ( + Source, + MemberRole, + Agency, + Unit, + Complaint, + Location, + Employment, +) from backend.database.models.user import User, UserRole from backend.database.models.officer import Officer from neomodel import db @@ -271,6 +279,85 @@ def test_primary_source_prefers_latest_change(add_test_change): assert officer.primary_source.uid == newer_source.uid +def test_get_source_activity( + client, + example_source, + example_user, + access_token, +): + agency = Agency( + name="Chicago Police Department Activity", + hq_state="IL", + hq_city="Chicago", + ).save() + unit = Unit( + name="Unit Activity", + hq_state="IL", + hq_city="Chicago", + ).save() + unit.agency.connect(agency) + + complaint = Complaint( + record_id="activity-complaint", + complaint_key="activity-complaint-key", + ).save() + complaint_location = Location(city="Springfield", state="IL").save() + complaint.location.connect(complaint_location) + + officer = Officer( + first_name="Activity", + last_name="Officer", + ).save() + employment = Employment(key="activity-employment").save() + employment.officer.connect(officer) + employment.unit.connect(unit) + + january_change = agency.add_change(source=example_source, user=example_user) + january_change.timestamp = datetime(2026, 1, 15, 10, 0, 0) + january_change.save() + + february_change = complaint.add_change( + source=example_source, + user=example_user, + ) + february_change.timestamp = datetime(2026, 2, 10, 10, 0, 0) + february_change.save() + + march_change_1 = unit.add_change(source=example_source, user=example_user) + march_change_1.timestamp = datetime(2026, 3, 5, 10, 0, 0) + march_change_1.save() + + march_change_2 = officer.add_change( + source=example_source, + user=example_user, + ) + march_change_2.timestamp = datetime(2026, 3, 20, 10, 0, 0) + march_change_2.save() + + res = client.get( + f"/api/v1/sources/{example_source.uid}/activity", + headers={"Authorization": f"Bearer {access_token}"} + ) + + assert res.status_code == 200 + assert res.json["total_changes"] == 4 + assert res.json["last_active_at"].startswith("2026-03-20") + assert len(res.json["contributions_over_time"]) == 12 + non_zero_months = [ + item for item in res.json["contributions_over_time"] + if item["count"] > 0 + ] + assert non_zero_months == [ + {"date": "2026-01-01", "count": 1}, + {"date": "2026-02-01", "count": 1}, + {"date": "2026-03-01", "count": 2}, + ] + assert res.json["contribution_locations"] == [ + {"label": "Chicago, IL", "count": 2}, + {"label": "Springfield, IL", "count": 1}, + ] + + # def test_add_member_to_source(db_session, example_members): # created = example_members["publisher"] diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 000000000..c3a0187fd --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,342 @@ +from backend.database import ( + MemberRole, + Source, + User, + EmailContact, + PhoneContact, + SocialMediaContact, +) +from backend.database.models.user import UserRole + + +def test_get_user_by_uid(client, access_token): + user = User.create_user( + email="lookup@example.com", + password="my_password", + role=UserRole.PUBLIC.value, + first_name="Lookup", + last_name="User", + phone_number="(555) 555-1111", + ) + user.website = "https://example.com" + user.city = "Brooklyn" + user.state = "NY" + user.organization = "NPDC" + user.title = "Researcher" + user.biography = "Profile lookup test user" + user.profile_image = "/tmp/profile.png" + user.save() + + secondary_email = EmailContact.get_or_create("lookup-secondary@example.com") + user.secondary_emails.connect(secondary_email) + + social = user.social_media_contacts.single() or SocialMediaContact().save() + social.linkedin_url = "https://linkedin.com/in/lookup-user" + social.save() + + res = client.get( + f"/api/v1/users/{user.uid}", + headers={"Authorization": f"Bearer {access_token}"} + ) + + assert res.status_code == 200 + assert res.json["uid"] == user.uid + assert res.json["first_name"] == "Lookup" + assert res.json["last_name"] == "User" + assert res.json["website"] == "https://example.com" + assert res.json["location"] == {"city": "Brooklyn", "state": "NY"} + assert res.json["employment"] == {"employer": "NPDC", "title": "Researcher"} + assert res.json["bio"] == "Profile lookup test user" + assert res.json["primary_email"] == "lookup@example.com" + assert res.json["contact_info"]["additional_emails"] == [ + "lookup-secondary@example.com" + ] + assert ( + res.json["social_media"]["linkedin_url"] + == "https://linkedin.com/in/lookup-user" + ) + assert "memberships" not in res.json + + +def test_get_user_by_uid_not_found(client, access_token): + res = client.get( + "/api/v1/users/not-a-real-user", + headers={"Authorization": f"Bearer {access_token}"} + ) + + assert res.status_code == 404 + assert res.json["message"] == "User not found" + + +def test_get_current_user_with_memberships_include( + client, + example_user, + access_token, +): + source = Source( + name="Example Affiliation", + url="https://example.org", + description="An affiliated source", + ).save() + source.members.connect( + example_user, + { + "role": MemberRole.ADMIN.value, + "is_active": True, + } + ) + + res = client.get( + "/api/v1/users/self?include=memberships", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert res.status_code == 200 + assert "memberships" in res.json + assert len(res.json["memberships"]) == 1 + assert res.json["memberships"][0]["source"] == { + "uid": source.uid, + "slug": source.slug, + "name": "Example Affiliation", + "description": "An affiliated source", + "website": "https://example.org", + } + assert res.json["memberships"][0]["role"] == MemberRole.ADMIN.value + assert res.json["memberships"][0]["is_active"] is True + assert res.json["memberships"][0]["date_joined"] is not None + + +def test_get_user_by_uid_rejects_invalid_include(client, access_token): + res = client.get( + "/api/v1/users/self?include=not-real", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert res.status_code == 400 + + +def test_get_people_suggestions(client, example_user, access_token): + shared_source_a = Source( + name="Shared Source A", + url="https://source-a.example.org", + ).save() + shared_source_b = Source( + name="Shared Source B", + url="https://source-b.example.org", + ).save() + unrelated_source = Source( + name="Unrelated Source", + url="https://unrelated.example.org", + ).save() + + shared_source_a.members.connect( + example_user, + { + "role": MemberRole.MEMBER.value, + "is_active": True, + } + ) + shared_source_b.members.connect( + example_user, + { + "role": MemberRole.MEMBER.value, + "is_active": True, + } + ) + + strongest_match = User.create_user( + email="shared-two@example.com", + password="my_password", + role=UserRole.PUBLIC.value, + first_name="Alicia", + last_name="Nguyen", + ) + strongest_match.title = "Investigator" + strongest_match.organization = "NPDC" + strongest_match.profile_image = "/tmp/alicia.png" + strongest_match.save() + + shared_source_a.members.connect( + strongest_match, + { + "role": MemberRole.ADMIN.value, + "is_active": True, + } + ) + shared_source_b.members.connect( + strongest_match, + { + "role": MemberRole.MEMBER.value, + "is_active": True, + } + ) + + second_match = User.create_user( + email="shared-one@example.com", + password="my_password", + role=UserRole.PUBLIC.value, + first_name="Brandon", + last_name="Young", + ) + second_match.title = "Analyst" + second_match.organization = "Civic Labs" + second_match.save() + + shared_source_a.members.connect( + second_match, + { + "role": MemberRole.MEMBER.value, + "is_active": True, + } + ) + + inactive_match = User.create_user( + email="inactive@example.com", + password="my_password", + role=UserRole.PUBLIC.value, + first_name="Inactive", + last_name="Member", + ) + shared_source_a.members.connect( + inactive_match, + { + "role": MemberRole.MEMBER.value, + "is_active": False, + } + ) + + unrelated_match = User.create_user( + email="unrelated@example.com", + password="my_password", + role=UserRole.PUBLIC.value, + first_name="Una", + last_name="Related", + ) + unrelated_source.members.connect( + unrelated_match, + { + "role": MemberRole.MEMBER.value, + "is_active": True, + } + ) + + res = client.get( + "/api/v1/users/self/suggestions/people?limit=2", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert res.status_code == 200 + assert [item["uid"] for item in res.json["results"]] == [ + strongest_match.uid, + second_match.uid, + ] + assert res.json["results"][0] == { + "uid": strongest_match.uid, + "first_name": "Alicia", + "last_name": "Nguyen", + "title": "Investigator", + "organization": "NPDC", + "profile_image": "/tmp/alicia.png", + "shared_source_count": 2, + "shared_sources": [ + { + "uid": shared_source_a.uid, + "slug": shared_source_a.slug, + "name": "Shared Source A", + }, + { + "uid": shared_source_b.uid, + "slug": shared_source_b.slug, + "name": "Shared Source B", + }, + ], + } + assert res.json["results"][1]["shared_source_count"] == 1 + + +def test_update_current_user_preserves_omitted_contact_fields( + client, + example_user, + access_token, +): + secondary_email = EmailContact.get_or_create( + "existing-secondary@example.com" + ) + example_user.secondary_emails.connect(secondary_email) + + phone_contact = PhoneContact.get_or_create("(555) 555-2222") + example_user.phone_contacts.connect(phone_contact) + + res = client.patch( + "/api/v1/users/self", + json={ + "website": "https://updated.example.com", + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + example_user.refresh() + + assert res.status_code == 200 + assert res.json["website"] == "https://updated.example.com" + assert res.json["contact_info"]["additional_emails"] == [ + "existing-secondary@example.com" + ] + assert set(res.json["contact_info"]["phone_numbers"]) == { + "(012) 345-6789", + "(555) 555-2222", + } + assert [email.email for email in example_user.secondary_emails.all()] == [ + "existing-secondary@example.com" + ] + assert { + phone.phone_number for phone in example_user.phone_contacts.all() + } == { + "(012) 345-6789", + "(555) 555-2222", + } + + +def test_update_current_user_only_updates_requested_nested_contact_field( + client, + example_user, + access_token, +): + secondary_email = EmailContact.get_or_create( + "existing-secondary@example.com" + ) + example_user.secondary_emails.connect(secondary_email) + + example_user.phone_contacts.connect( + PhoneContact.get_or_create("(555) 555-2222") + ) + + res = client.patch( + "/api/v1/users/self", + json={ + "contact_info": { + "additional_emails": ["new-secondary@example.com"], + }, + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + example_user.refresh() + + assert res.status_code == 200 + assert res.json["contact_info"]["additional_emails"] == [ + "new-secondary@example.com" + ] + assert set(res.json["contact_info"]["phone_numbers"]) == { + "(012) 345-6789", + "(555) 555-2222", + } + assert [email.email for email in example_user.secondary_emails.all()] == [ + "new-secondary@example.com" + ] + assert { + phone.phone_number for phone in example_user.phone_contacts.all() + } == { + "(012) 345-6789", + "(555) 555-2222", + } diff --git a/frontend/app/organization/page.tsx b/frontend/app/organization/page.tsx index 4d7ebc22b..b50350eb7 100644 --- a/frontend/app/organization/page.tsx +++ b/frontend/app/organization/page.tsx @@ -1,50 +1,21 @@ "use client" -import React, { useEffect, useState } from "react" -import OrganizationProfile from "@/components/Profile/OrganizationProfile" -import { useOrganization } from "@/utils/useProfile" -import { useAuth } from "@/providers/AuthProvider" -import { apiFetch } from "@/utils/apiFetch" -import API_ROUTES, { apiBaseUrl } from "@/utils/apiRoutes" -import { Organization } from "@/utils/api" -import { useSearchParams } from "next/navigation" +import { useEffect } from "react" +import { useRouter, useSearchParams } from "next/navigation" export default function OrganizationPage() { + const router = useRouter() const searchParams = useSearchParams() const slug = searchParams.get("slug") - const { profile: organization, loading } = useOrganization(slug || "") - const { accessToken } = useAuth() - const [allOrgs, setAllOrgs] = useState([]) useEffect(() => { - if (accessToken) { - apiFetch(`${apiBaseUrl}${API_ROUTES.sources.all}`, { - headers: { - Authorization: `Bearer ${accessToken}` - } - }) - .then((res) => res.json()) - .then((data) => setAllOrgs(data.results || data)) + if (slug) { + router.replace(`/sources/${slug}`) + return } - }, [accessToken]) - if (loading) { - return
Loading organization...
- } + router.replace("/404") + }, [router, slug]) - if (!organization) { - return ( -
-

Organization {slug} not found.

-

Available organizations:

-
    - {allOrgs.map((org) => ( -
  • {org.name}
  • - ))} -
-
- ) - } - - return + return
Redirecting...
} diff --git a/frontend/app/profile/[uid]/page.tsx b/frontend/app/profile/[uid]/page.tsx new file mode 100644 index 000000000..827ccd042 --- /dev/null +++ b/frontend/app/profile/[uid]/page.tsx @@ -0,0 +1,75 @@ +"use client" + +import React from "react" +import { useParams } from "next/navigation" +import { usePeopleSuggestions, useUserProfile } from "@/utils/useProfile" +import SuggestionsCard from "@/components/Profile/SuggestionsCard" +import ProfileLayout from "@/components/Profile/ProfileLayout" +import ProfileHeaderCard from "@/components/Profile/ProfileHeaderCard" +import OrganizationCard from "@/components/Profile/OrganizationCard" +import ContactCard from "@/components/Profile/ContactCard" +import { SocialMedia } from "@/utils/api" + +export default function UserProfilePage() { + const params = useParams<{ uid: string }>() + const uid = params.uid + const { profile, loading, isOwnProfile } = useUserProfile(uid) + const { suggestions } = usePeopleSuggestions(4) + const canEdit = isOwnProfile + + if (loading) return

Loading profile...

+ if (!profile) return

Unable to load profile.

+ + const peopleSuggestions = suggestions.map((person) => ({ + name: `${person.first_name} ${person.last_name}`.trim(), + title: person.title || person.organization || "", + avatarUrl: person.profile_image || "/broken-image.jpg", + href: `/profile/${person.uid}` + })) + + const socialMediaContacts = profile.social_media || ({} as SocialMedia) + + return ( + + + + ) : undefined + } + > + + + +
+ + ) +} diff --git a/frontend/app/profile/contact/edit/page.tsx b/frontend/app/profile/contact/edit/page.tsx index 5ad982202..031f91fbb 100644 --- a/frontend/app/profile/contact/edit/page.tsx +++ b/frontend/app/profile/contact/edit/page.tsx @@ -115,7 +115,7 @@ export default function EditProfileContact() { await updateProfile(payload) - router.push("/profile") + router.push(`/profile/${profile.uid}`) } } catch (e) { console.error("Failed to update profile:", e) @@ -131,7 +131,11 @@ export default function EditProfileContact() { return (
- +
diff --git a/frontend/app/profile/edit/page.tsx b/frontend/app/profile/edit/page.tsx index 0745e0035..950d0b473 100644 --- a/frontend/app/profile/edit/page.tsx +++ b/frontend/app/profile/edit/page.tsx @@ -93,7 +93,7 @@ export default function EditProfilePage() { } await updateProfile(payload as Partial) - router.push("/profile") + router.push(`/profile/${profile.uid}`) } } catch (e) { console.error("Profile update failed", e) @@ -109,7 +109,11 @@ export default function EditProfilePage() { return (
- +
diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 2c64c689f..25ef12334 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,71 +1,19 @@ "use client" -import React from "react" +import React, { useEffect } from "react" import { useUserProfile } from "@/utils/useProfile" -import SuggestionsCard from "@/components/Profile/SuggestionsCard" -import ProfileLayout from "@/components/Profile/ProfileLayout" -import ProfileHeaderCard from "@/components/Profile/ProfileHeaderCard" -import OrganizationCard from "@/components/Profile/OrganizationCard" -import ContactCard from "@/components/Profile/ContactCard" -import { SocialMedia } from "@/utils/api" +import { useRouter } from "next/navigation" export default function ProfilePage() { const { profile, loading } = useUserProfile() + const router = useRouter() - if (loading) return

Loading profile...

- if (!profile) return

Unable to load profile.

- - const peopleSuggestions = [ - { name: "Samuel Smith", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Marian Linehan", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "June MacCabe", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Joseph Vanasse", title: "Title", avatarUrl: "/broken-image.jpg" } - ] - - const orgSuggestions = [ - { name: "Law Firm Name 1", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Law Firm Name 2", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Law Firm Name 3", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Law Firm Name 4", title: "Title", avatarUrl: "/broken-image.jpg" } - ] + useEffect(() => { + if (profile?.uid) { + router.replace(`/profile/${profile.uid}`) + } + }, [profile?.uid, router]) - const socialMediaContacts = profile.social_media || ({} as SocialMedia) - - return ( - - - - - } - > - - - -
- - ) + if (loading) return

Loading profile...

+ return

Redirecting...

} diff --git a/frontend/app/sources/[identifier]/edit/page.tsx b/frontend/app/sources/[identifier]/edit/page.tsx new file mode 100644 index 000000000..de3bbecc1 --- /dev/null +++ b/frontend/app/sources/[identifier]/edit/page.tsx @@ -0,0 +1,266 @@ +"use client" + +import React, { useEffect, useMemo, useState } from "react" +import styles from "../../../profile/edit/EditProfilePage.module.css" +import { useParams, useRouter } from "next/navigation" +import ArrowBackIcon from "@mui/icons-material/ArrowBack" +import { Box, Button, IconButton, TextField, Typography } from "@mui/material" +import { apiFetch } from "@/utils/apiFetch" +import API_ROUTES, { apiBaseUrl } from "@/utils/apiRoutes" +import { useOrganization, useUserProfile } from "@/utils/useProfile" +import { UpdateOrganizationPayload } from "@/utils/api" + +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +const isValidUrl = (url: string) => { + if (!url) return true + try { + new URL(url.startsWith("http") ? url : `https://${url}`) + return true + } catch { + return false + } +} + +export default function EditSourcePage() { + const params = useParams<{ identifier: string }>() + const identifier = params.identifier + const router = useRouter() + const { profile: organization, loading } = useOrganization(identifier) + const { profile: currentUser, loading: userLoading } = useUserProfile() + const [saving, setSaving] = useState(false) + + const [name, setName] = useState("") + const [slug, setSlug] = useState("") + const [email, setEmail] = useState("") + const [website, setWebsite] = useState("") + const [description, setDescription] = useState("") + const [linkedIn, setLinkedIn] = useState("") + const [facebook, setFacebook] = useState("") + const [instagram, setInstagram] = useState("") + const [twitter, setTwitter] = useState("") + const [youtube, setYoutube] = useState("") + + const [errors, setErrors] = useState({ + name: "", + slug: "", + email: "", + website: "", + description: "", + linkedIn: "", + facebook: "", + instagram: "", + twitter: "", + youtube: "" + }) + + const canEdit = useMemo( + () => + !!currentUser?.uid && + organization?.memberships?.some( + (membership) => membership.uid === currentUser.uid && membership.role === "Administrator" + ) === true, + [currentUser?.uid, organization?.memberships] + ) + + useEffect(() => { + if (organization) { + setName(organization.name || "") + setSlug(organization.slug || "") + setEmail(organization.email || "") + setWebsite(organization.website || "") + setDescription(organization.description || "") + setLinkedIn(organization.social_media?.linkedin_url || "") + setFacebook(organization.social_media?.facebook_url || "") + setInstagram(organization.social_media?.instagram_url || "") + setTwitter(organization.social_media?.twitter_url || "") + setYoutube(organization.social_media?.youtube_url || "") + } + }, [organization]) + + useEffect(() => { + if (!loading && !userLoading && organization && !canEdit) { + router.replace(organization.uid ? `/sources/${organization.uid}` : "/404") + } + }, [canEdit, loading, organization, router, userLoading]) + + const validateForm = () => { + const newErrors = { + name: name.trim() ? "" : "Organization name is required", + slug: slug.trim() ? "" : "Slug is required", + email: !email.trim() + ? "Primary email is required" + : !isValidEmail(email) + ? "Enter a valid email address" + : "", + website: website && !isValidUrl(website) ? "Enter a valid website URL" : "", + description: description.length > 500 ? "Description must be 500 characters or less" : "", + linkedIn: linkedIn && !isValidUrl(linkedIn) ? "Enter a valid LinkedIn URL" : "", + facebook: facebook && !isValidUrl(facebook) ? "Enter a valid Facebook URL" : "", + instagram: instagram && !isValidUrl(instagram) ? "Enter a valid Instagram URL" : "", + twitter: twitter && !isValidUrl(twitter) ? "Enter a valid Twitter URL" : "", + youtube: youtube && !isValidUrl(youtube) ? "Enter a valid YouTube URL" : "" + } + + setErrors(newErrors) + return Object.values(newErrors).every((msg) => msg === "") + } + + const handleSubmit = async () => { + if (!organization?.uid || !validateForm()) return + + setSaving(true) + try { + const payload: UpdateOrganizationPayload = { + name, + slug, + contact_email: email, + url: website, + description, + social_media: { + linkedin_url: linkedIn || "", + facebook_url: facebook || "", + instagram_url: instagram || "", + twitter_url: twitter || "", + youtube_url: youtube || "" + } + } + + const res = await apiFetch(`${apiBaseUrl}${API_ROUTES.sources.profile(organization.uid)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }) + + if (!res.ok) { + throw new Error("Failed to update source") + } + + const updated = await res.json() + router.push(`/sources/${updated.slug || updated.uid}`) + } catch (e) { + console.error("Source update failed", e) + alert("Failed to update organization.") + } finally { + setSaving(false) + } + } + + if (loading || userLoading) return

Loading...

+ if (!organization) return

Unable to load organization.

+ if (!canEdit) return

Checking permissions...

+ + return ( +
+
+ + + +
+ + +
+ Organization Details + + setName(e.target.value)} + error={!!errors.name} + helperText={errors.name} + /> + + setSlug(e.target.value)} + error={!!errors.slug} + helperText={errors.slug} + /> + + setDescription(e.target.value)} + error={!!errors.description} + helperText={errors.description || `${description.length}/500`} + multiline + minRows={4} + /> +
+ +
+ Contact Info + + setEmail(e.target.value)} + error={!!errors.email} + helperText={errors.email} + /> + + setWebsite(e.target.value)} + error={!!errors.website} + helperText={errors.website} + /> +
+ +
+ Social Media Links + + setLinkedIn(e.target.value)} + error={!!errors.linkedIn} + helperText={errors.linkedIn} + /> + setFacebook(e.target.value)} + error={!!errors.facebook} + helperText={errors.facebook} + /> + setInstagram(e.target.value)} + error={!!errors.instagram} + helperText={errors.instagram} + /> + setTwitter(e.target.value)} + error={!!errors.twitter} + helperText={errors.twitter} + /> + setYoutube(e.target.value)} + error={!!errors.youtube} + helperText={errors.youtube} + /> +
+ + +
+
+ ) +} diff --git a/frontend/app/sources/[identifier]/page.tsx b/frontend/app/sources/[identifier]/page.tsx new file mode 100644 index 000000000..fc2f697d0 --- /dev/null +++ b/frontend/app/sources/[identifier]/page.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import OrganizationProfile from "@/components/Profile/OrganizationProfile" +import { useOrganization } from "@/utils/useProfile" +import { useAuth } from "@/providers/AuthProvider" +import { apiFetch } from "@/utils/apiFetch" +import API_ROUTES, { apiBaseUrl } from "@/utils/apiRoutes" +import { SourceActivity, SourceMember } from "@/utils/api" +import { useUserProfile } from "@/utils/useProfile" + +export default function SourcePage() { + const params = useParams<{ identifier: string }>() + const identifier = params.identifier + const { profile: organization, loading } = useOrganization(identifier) + const { profile: currentUser } = useUserProfile() + const { accessToken } = useAuth() + const [members, setMembers] = useState([]) + const [activity, setActivity] = useState(null) + const [activityLoading, setActivityLoading] = useState(false) + + useEffect(() => { + if (!accessToken || !organization?.uid) return + + apiFetch(`${apiBaseUrl}${API_ROUTES.sources.members(organization.uid)}`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to load source members") + } + + return res.json() + }) + .then((data) => setMembers(data.results || data)) + .catch((error) => { + console.error(error) + setMembers([]) + }) + }, [accessToken, organization?.uid]) + + useEffect(() => { + if (!accessToken || !organization?.uid) return + + setActivityLoading(true) + apiFetch(`${apiBaseUrl}${API_ROUTES.sources.activity(organization.uid)}`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to load source activity") + } + + return res.json() + }) + .then((data) => setActivity(data)) + .catch((error) => { + console.error(error) + setActivity(null) + }) + .finally(() => setActivityLoading(false)) + }, [accessToken, organization?.uid]) + + if (loading) { + return
Loading organization...
+ } + + if (!organization) { + return
Organization not found.
+ } + + const canEdit = + !!currentUser?.uid && + organization.memberships?.some( + (membership) => membership.uid === currentUser.uid && membership.role === "Administrator" + ) === true + + return ( + + ) +} diff --git a/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx b/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx index 8047be056..adee0e263 100644 --- a/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx +++ b/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx @@ -1,29 +1,30 @@ import ContentDetails from "./ContentDetails" import { Agency } from "@/utils/api" +import { getSourceHref } from "@/utils/sourceRoutes" type AgencyContentDetailsProps = { agency: Agency } -const toOrganizationSlug = (name: string) => - name - .toLowerCase() - .replace(/\s+/g, "-") - .replace(".", "-") - .replace(/[^a-z0-9-]/g, "") - export default function AgencyContentDetails({ agency }: AgencyContentDetailsProps) { const totalComplaints = agency.total_complaints || 0 const totalOfficers = agency.total_officers || 0 const dataSources = - agency.sources - ?.filter((source): source is { name: string; uid?: string } => Boolean(source.name)) - .map((source) => ({ - label: source.name, - href: `/organization?slug=${toOrganizationSlug(source.name)}` - })) || [] + agency.sources?.flatMap((source) => { + if (!source.name) return [] + + const href = getSourceHref(source) + if (!href) return [] + + return [ + { + label: source.name, + href + } + ] + }) || [] return ( - name - .toLowerCase() - .replace(/\s+/g, "-") - .replace(".", "-") - .replace(/[^a-z0-9-]/g, "") - export default function OfficerContentDetails({ officer }: OfficerContentDetailsProps) { const totalComplaints = officer.allegation_summary?.reduce((sum, a) => sum + a.complaint_count, 0) || 0 @@ -22,12 +16,19 @@ export default function OfficerContentDetails({ officer }: OfficerContentDetails officer.allegation_summary?.reduce((sum, a) => sum + a.substantiated_count, 0) || 0 const dataSources = - officer.sources - ?.filter((source): source is { name: string; uid?: string } => Boolean(source.name)) - .map((source) => ({ - label: source.name, - href: `/organization?slug=${toOrganizationSlug(source.name)}` - })) || [] + officer.sources?.flatMap((source) => { + if (!source.name) return [] + + const href = getSourceHref(source) + if (!href) return [] + + return [ + { + label: source.name, + href + } + ] + }) || [] return ( - name - .toLowerCase() - .replace(/\s+/g, "-") - .replace(".", "-") - .replace(/[^a-z0-9-]/g, "") - export default function UnitContentDetails({ unit }: UnitContentDetailsProps) { const totalComplaints = unit.total_complaints || 0 @@ -20,12 +14,19 @@ export default function UnitContentDetails({ unit }: UnitContentDetailsProps) { const totalOfficers = unit.total_officers || 0 const dataSources = - unit.sources - ?.filter((source): source is { name: string; uid?: string } => Boolean(source.name)) - .map((source) => ({ - label: source.name, - href: `/organization?slug=${toOrganizationSlug(source.name)}` - })) || [] + unit.sources?.flatMap((source) => { + if (!source.name) return [] + + const href = getSourceHref(source) + if (!href) return [] + + return [ + { + label: source.name, + href + } + ] + }) || [] return ( + new Date(`${date}T00:00:00`).toLocaleDateString("en-US", { + month: "short", + year: "numeric" + }) + +const formatLastActive = (timestamp: string | null) => { + if (!timestamp) return "No recorded activity yet" + + return `Last active ${new Date(timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric" + })}` +} + +function ContributionHistoryChart({ data }: { data: SourceActivity["contributions_over_time"] }) { + return ( + point.date), + valueFormatter: formatDateLabel, + tickLabelStyle: { + fontSize: 12 + } + } + ]} + yAxis={[ + { + tickLabelStyle: { + fontSize: 12 + } + } + ]} + series={[ + { + id: "contributions", + label: "Contributions", + data: data.map((point) => point.count), + color: "#D500F9", + layout: "vertical" + } + ]} + grid={{ horizontal: true }} + hideLegend + sx={{ + width: "100%", + "& .MuiBarElement-root": { + rx: 6, + ry: 6 + } + }} + /> + ) +} + +function ContributionLocationChart({ data }: { data: SourceActivity["contribution_locations"] }) { + if (!data.length) { + return No location data available yet. + } -export default function ActivityCard() { return ( - + ({ + id: slice.label, + value: slice.count, + label: `${slice.label} (${slice.count})`, + color: PIE_COLORS[index % PIE_COLORS.length] + })), + innerRadius: 0, + outerRadius: 90, + paddingAngle: 2, + cornerRadius: 4 + } + ]} + slotProps={{ + legend: { + direction: "vertical", + position: { + vertical: "middle", + horizontal: "end" + }, + sx: { + "& .MuiChartsLegend-label": { + fontSize: 16 + } + } + } + }} + sx={{ + width: "100%" + }} + /> + ) +} + +export default function ActivityCard({ + activity, + loading = false +}: { + activity: SourceActivity | null + loading?: boolean +}) { + return ( + - - Activity - - - Last active Oct 24, 2025 - - {/* TODO: Add contribution history and location data viz */} +
+ + Activity + + + {formatLastActive(activity?.last_active_at || null)} + +
+ +
+
+ + Contribution History + + {loading ? ( + + + + ) : ( + + )} +
+ +
+ + Locations of Contributions + + {loading ? ( + + + + ) : ( + + )} +
+
) diff --git a/frontend/components/Profile/ContactCard.tsx b/frontend/components/Profile/ContactCard.tsx index 0b8f411f4..f845b95b3 100644 --- a/frontend/components/Profile/ContactCard.tsx +++ b/frontend/components/Profile/ContactCard.tsx @@ -1,6 +1,6 @@ "use client" -import { Card, CardContent, Typography, IconButton } from "@mui/material" +import { Card, CardContent, Typography, IconButton, Button } from "@mui/material" import ModeEditOutlinedIcon from "@mui/icons-material/ModeEditOutlined" import EmailIcon from "@mui/icons-material/Email" import PublicIcon from "@mui/icons-material/Public" @@ -24,6 +24,8 @@ interface Props { youtube?: string } isOwnProfile?: boolean + canEdit?: boolean + editHref?: string } export default function ContactCard({ @@ -31,7 +33,9 @@ export default function ContactCard({ secondaryEmail, website, socials, - isOwnProfile + isOwnProfile, + canEdit, + editHref }: Props) { const router = useRouter() const hasSocials = Object.values(socials).some((val) => !!val) @@ -45,7 +49,15 @@ export default function ContactCard({ } return ( - + - {isOwnProfile && ( + {canEdit ? ( + + ) : null} + {isOwnProfile ? ( - router.push("/profile/contact/edit")} /> + router.push(editHref || "/profile/contact/edit")} + /> - )} + ) : null} - + Contact
-
- -
-

Email

- - {primaryEmail} - + {primaryEmail ? ( +
+ +
-
+ ) : null} {secondaryEmail && (
@@ -109,7 +148,15 @@ export default function ContactCard({ {hasSocials && ( <> - + Socials
diff --git a/frontend/components/Profile/OrganizationCard.tsx b/frontend/components/Profile/OrganizationCard.tsx index 456d82834..510e84c10 100644 --- a/frontend/components/Profile/OrganizationCard.tsx +++ b/frontend/components/Profile/OrganizationCard.tsx @@ -1,8 +1,27 @@ import React from "react" +import Link from "next/link" import { Avatar, Card, CardContent, Typography } from "@mui/material" +import { UserMembership } from "@/utils/api" import styles from "./organizationCard.module.css" -export default function OrganizationCard() { +const formatJoinedDate = (dateJoined?: string) => { + if (!dateJoined) return null + + const date = new Date(dateJoined) + if (Number.isNaN(date.getTime())) return null + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric" + }) +} + +export default function OrganizationCard({ memberships }: { memberships?: UserMembership[] }) { + if (!memberships?.length) { + return null + } + return ( - Organization Affiliation + Source Affiliation -
- -
- - Organization Name - - Bio from the organization’s profile. -
-
- Joined on Oct 18, 2024 -
- -
- - Organization Name - - Bio from the organization’s profile. -
-
- Joined on Oct 18, 2024 + + {memberships.map((membership) => { + const joinedDate = formatJoinedDate(membership.date_joined) + + return ( +
+
+ + + +
+ + + {membership.source.name} + + + {membership.source.description ? ( + {membership.source.description} + ) : null} + {membership.role ? ( + {membership.role} + ) : null} +
+
+ {joinedDate ? ( + Joined on {joinedDate} + ) : null} +
+ ) + })}
) diff --git a/frontend/components/Profile/OrganizationMembersCard.tsx b/frontend/components/Profile/OrganizationMembersCard.tsx index 1aff2b02a..c1ae7c644 100644 --- a/frontend/components/Profile/OrganizationMembersCard.tsx +++ b/frontend/components/Profile/OrganizationMembersCard.tsx @@ -1,63 +1,23 @@ -import React, { useState } from "react" +import React from "react" import { Avatar, Button, Card, CardContent, Typography } from "@mui/material" import styles from "./organizationMembersCard.module.css" +import { SourceMember } from "@/utils/api" -// TODO: Replace with real data -const members = [ - { - firstName: "Jonathan", - lastName: "Watkins", - avatarUrl: "/broken-image.jpg", - title: "Title", - company: "Company Name", - id: 0 - }, - { - firstName: "Jonathan", - lastName: "Watkins", - avatarUrl: "/broken-image.jpg", - title: "Title", - company: "Company Name", - id: 1 - }, - { - firstName: "Jonathan", - lastName: "Watkins", - avatarUrl: "/broken-image.jpg", - title: "Title", - company: "Company Name", - id: 2 - }, - { - firstName: "Jonathan", - lastName: "Watkins", - avatarUrl: "/broken-image.jpg", - title: "Title", - company: "Company Name", - id: 3 - }, - { - firstName: "Jonathan", - lastName: "Watkins", - avatarUrl: "/broken-image.jpg", - title: "Title", - company: "Company Name", - id: 4 - }, - { - firstName: "Jonathan", - lastName: "Watkins", - avatarUrl: "/broken-image.jpg", - title: "Title", - company: "Company Name", - id: 5 +export default function OrganizationMembers({ members }: { members: SourceMember[] }) { + if (!members.length) { + return null } -] -export default function OrganizationMembers() { - const [isFollowing, setIsFollowing] = useState(false) return ( - + - + Organization Members
{members.map((item) => { return ( -
+
-

{item.firstName}

-

{item.lastName}

-

{item.title}

-

{item.company}

+

{`${item.first_name} ${item.last_name}`}

+ {item.title ?

{item.title}

: null} + {item.organization ?

{item.organization}

: null} diff --git a/frontend/components/Profile/OrganizationProfile.tsx b/frontend/components/Profile/OrganizationProfile.tsx index d5d3d4c91..dd3a49cca 100644 --- a/frontend/components/Profile/OrganizationProfile.tsx +++ b/frontend/components/Profile/OrganizationProfile.tsx @@ -1,39 +1,28 @@ "use client" import React from "react" -import SuggestionsCard from "@/components/Profile/SuggestionsCard" import ProfileLayout from "@/components/Profile/ProfileLayout" import ProfileHeaderCard from "@/components/Profile/ProfileHeaderCard" import ContactCard from "@/components/Profile/ContactCard" import OrganizationMembers from "@/components/Profile/OrganizationMembersCard" import ActivityCard from "@/components/Profile/ActivityCard" -import { Organization } from "@/utils/api" - -export default function OrganizationProfile({ organization }: { organization: Organization }) { - // TODO: Replace with real data - const peopleSuggestions = [ - { name: "Samuel Smith", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Marian Linehan", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "June MacCabe", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Joseph Vanasse", title: "Title", avatarUrl: "/broken-image.jpg" } - ] - - const orgSuggestions = [ - { name: "Law Firm Name 1", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Law Firm Name 2", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Law Firm Name 3", title: "Title", avatarUrl: "/broken-image.jpg" }, - { name: "Law Firm Name 4", title: "Title", avatarUrl: "/broken-image.jpg" } - ] +import { Organization, SourceActivity, SourceMember } from "@/utils/api" +export default function OrganizationProfile({ + organization, + members, + canEdit = false, + activity, + activityLoading = false +}: { + organization: Organization + members: SourceMember[] + canEdit?: boolean + activity?: SourceActivity | null + activityLoading?: boolean +}) { return ( - - - - - } - > + - - + +
) diff --git a/frontend/components/Profile/ProfileHeaderCard.tsx b/frontend/components/Profile/ProfileHeaderCard.tsx index 5797f0f11..512d0db29 100644 --- a/frontend/components/Profile/ProfileHeaderCard.tsx +++ b/frontend/components/Profile/ProfileHeaderCard.tsx @@ -16,6 +16,9 @@ interface Props { city?: string state?: string isOwnProfile?: boolean + canEdit?: boolean + editHref?: string + showFollowerStats?: boolean } export default function ProfileHeaderCard({ @@ -27,14 +30,20 @@ export default function ProfileHeaderCard({ organization, city, state, - isOwnProfile + isOwnProfile, + canEdit, + editHref, + showFollowerStats = true }: Props) { const router = useRouter() const [isFollowing, setIsFollowing] = useState(false) return ( - + - {isOwnProfile && ( + {canEdit ? ( + + ) : null} + {isOwnProfile ? ( - router.push("/profile/edit")} /> + router.push(editHref || "/profile/edit")} /> - )} + ) : null}
- + {firstName} {lastName}
- {title && {title}} - {organization && {organization}} + {title && {title}} + {organization && {organization}} {(city || state) && ( - {[city, state].filter(Boolean).join(", ")} + + {[city, state].filter(Boolean).join(", ")} + )}
-

{biography}

- {!isOwnProfile && ( +

{biography}

+ {!isOwnProfile && !canEdit && (
)} -
50 followers • 30 following
+ {showFollowerStats ? ( +
50 followers • 30 following
+ ) : null}
diff --git a/frontend/components/Profile/ProfileLayout.tsx b/frontend/components/Profile/ProfileLayout.tsx index dc58e5b13..1e7c61b6b 100644 --- a/frontend/components/Profile/ProfileLayout.tsx +++ b/frontend/components/Profile/ProfileLayout.tsx @@ -11,7 +11,7 @@ export default function ProfileLayout({ children, sidebar }: ProfileLayoutProps)
{children}
-
{sidebar}
+ {sidebar ?
{sidebar}
: null}
) diff --git a/frontend/components/Profile/SuggestionsCard.tsx b/frontend/components/Profile/SuggestionsCard.tsx index 728845ab2..23acb57b5 100644 --- a/frontend/components/Profile/SuggestionsCard.tsx +++ b/frontend/components/Profile/SuggestionsCard.tsx @@ -4,12 +4,14 @@ import Button from "@mui/material/Button" import Card from "@mui/material/Card" import CardContent from "@mui/material/CardContent" import Typography from "@mui/material/Typography" +import Link from "next/link" import styles from "./suggestionsCard.module.css" interface Suggestion { name: string title: string avatarUrl?: string + href?: string } interface SuggestionsCardProps { @@ -59,9 +61,21 @@ export default function SuggestionsCard({ const isFollowing = followedUsers.has(item.name) return (
- + {item.href ? ( + + + + ) : ( + + )}
-

{item.name}

+ {item.href ? ( + +

{item.name}

+ + ) : ( +

{item.name}

+ )}

{item.title}