From b82b99d8931eb018693ea1527c8bf1f4fee2c815 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sat, 18 Apr 2026 13:41:44 -0400 Subject: [PATCH 01/11] Update source URL paths --- backend/database/models/source.py | 26 +++++++-- frontend/app/organization/page.tsx | 47 +++------------- frontend/app/sources/[identifier]/page.tsx | 21 +++++++ .../ContentDetails/AgencyContentDetails.tsx | 24 ++++---- .../ContentDetails/OfficerContentDetails.tsx | 24 ++++---- .../ContentDetails/UnitContentDetails.tsx | 24 ++++---- frontend/utils/api.ts | 2 + frontend/utils/apiRoutes.ts | 4 +- frontend/utils/sourceRoutes.ts | 17 ++++++ frontend/utils/useProfile.ts | 55 +++++++++++++++---- 10 files changed, 152 insertions(+), 92 deletions(-) create mode 100644 frontend/app/sources/[identifier]/page.tsx create mode 100644 frontend/utils/sourceRoutes.ts diff --git a/backend/database/models/source.py b/backend/database/models/source.py index f42ebcd62..85ef97828 100644 --- a/backend/database/models/source.py +++ b/backend/database/models/source.py @@ -368,17 +368,28 @@ 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 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( @@ -412,12 +423,17 @@ def create_source( source = cls( name=name, url=url - ).save() + ) + 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/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/sources/[identifier]/page.tsx b/frontend/app/sources/[identifier]/page.tsx new file mode 100644 index 000000000..7b252cac0 --- /dev/null +++ b/frontend/app/sources/[identifier]/page.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useParams } from "next/navigation" +import OrganizationProfile from "@/components/Profile/OrganizationProfile" +import { useOrganization } from "@/utils/useProfile" + +export default function SourcePage() { + const params = useParams<{ identifier: string }>() + const identifier = params.identifier + const { profile: organization, loading } = useOrganization(identifier) + + if (loading) { + return
Loading organization...
+ } + + if (!organization) { + return
Organization not found.
+ } + + return +} diff --git a/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx b/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx index 8047be056..bed419b58 100644 --- a/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx +++ b/frontend/components/Details/ContentDetails/AgencyContentDetails.tsx @@ -1,17 +1,11 @@ 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 @@ -19,11 +13,17 @@ export default function AgencyContentDetails({ agency }: AgencyContentDetailsPro 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)}` - })) || [] + ?.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 @@ -23,11 +17,17 @@ export default function OfficerContentDetails({ officer }: OfficerContentDetails 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)}` - })) || [] + ?.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 @@ -21,11 +15,17 @@ export default function UnitContentDetails({ unit }: UnitContentDetailsProps) { 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)}` - })) || [] + ?.flatMap((source) => { + if (!source.name) return [] + + const href = getSourceHref(source) + if (!href) return [] + + return [{ + label: source.name, + href + }] + }) || [] return ( `/sources/${identifier}`, + profileBySlug: (slug: string) => `/sources/slug/${slug}` }, officers: { profile: (slug: string) => `/officers/${slug}` diff --git a/frontend/utils/sourceRoutes.ts b/frontend/utils/sourceRoutes.ts new file mode 100644 index 000000000..87c3572c1 --- /dev/null +++ b/frontend/utils/sourceRoutes.ts @@ -0,0 +1,17 @@ +import { Source } from "@/utils/api" + +export const generateSourceSlug = (name: string): string => + name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(".", "-") + .replace(/[^a-z0-9-]/g, "") + +export const getSourceIdentifier = (source: Pick): string | null => + source.uid || source.slug || (source.name ? generateSourceSlug(source.name) : null) + +export const getSourceHref = (source: Pick): string | null => { + const identifier = getSourceIdentifier(source) + + return identifier ? `/sources/${identifier}` : null +} diff --git a/frontend/utils/useProfile.ts b/frontend/utils/useProfile.ts index b885225f0..eaa87c797 100644 --- a/frontend/utils/useProfile.ts +++ b/frontend/utils/useProfile.ts @@ -19,7 +19,7 @@ type ProfileType = "user" | "organization" export const useProfile = ( type: ProfileType, - slug?: string + identifier?: string ) => { const { hasHydrated, accessToken } = useAuth() const router = useRouter() @@ -39,7 +39,7 @@ export const useProfile = ( if (type === "user") { let matchedProfile: UserProfile | null = null - if (slug) { + if (identifier) { // Fetch current user first to check if slug matches them const selfRes = await apiFetch(`${apiBaseUrl}${API_ROUTES.users.self}`, { headers: { @@ -55,7 +55,7 @@ export const useProfile = ( const selfData: UserProfile = await selfRes.json() const selfUsername = generateSlug(selfData.first_name, selfData.last_name) - if (slug === selfUsername) { + if (identifier === selfUsername) { matchedProfile = selfData setIsOwnProfile(true) } else { @@ -99,23 +99,53 @@ export const useProfile = ( const data = await res.json() const sources = data.results || data - if (!slug) { + if (!identifier) { setProfile(null) setLoading(false) return } - const matchedSource = sources.find( - (source: { name: string }) => generateSlug(source.name) === slug - ) + const fetchOrganization = async () => { + const headers = { + Authorization: `Bearer ${accessToken}` + } - if (!matchedSource) { - router.push("/404") - return + const byUidResponse = await apiFetch( + `${apiBaseUrl}${API_ROUTES.sources.profile(identifier)}`, + { headers } + ) + + if (byUidResponse.ok) { + return byUidResponse.json() + } + + if (byUidResponse.status !== 404) { + throw new Error("Failed to fetch organization by identifier") + } + + const bySlugResponse = await apiFetch( + `${apiBaseUrl}${API_ROUTES.sources.profileBySlug(identifier)}`, + { headers } + ) + + if (!bySlugResponse.ok) { + if (bySlugResponse.status === 404) { + router.push("/404") + return null + } + throw new Error("Failed to fetch organization by slug") + } + + return bySlugResponse.json() } + const matchedSource = await fetchOrganization() + + if (!matchedSource) return + const organization: Organization = { uid: matchedSource.uid, + slug: matchedSource.slug, name: matchedSource.name, description: matchedSource.description || "", logo: matchedSource.logo || "/broken-image.jpg", @@ -139,7 +169,7 @@ export const useProfile = ( } fetchProfile() - }, [hasHydrated, accessToken, type, slug, router]) + }, [hasHydrated, accessToken, type, identifier, router]) // Update user profile (only works for own profile) const updateProfile = async ( @@ -191,4 +221,5 @@ export const useProfile = ( } export const useUserProfile = (slug?: string) => useProfile("user", slug) -export const useOrganization = (slug?: string) => useProfile("organization", slug) +export const useOrganization = (identifier?: string) => + useProfile("organization", identifier) From c9d3555126d4a682c736519f5725d8e8d07e5347 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sat, 18 Apr 2026 15:05:22 -0400 Subject: [PATCH 02/11] Add activity metrics for sources --- backend/database/models/source.py | 101 +++++++++++++++++++++++++++++- backend/routes/sources.py | 14 +++++ backend/tests/test_sources.py | 65 ++++++++++++++++++- 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/backend/database/models/source.py b/backend/database/models/source.py index 85ef97828..1a3ef3327 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,105 @@ 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.""" + 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}) + + contributions_over_time = [ + {"date": row[0], "count": row[1]} + for row in rows + ] + + 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. @@ -376,6 +470,9 @@ def update_source( 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() @@ -398,6 +495,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. @@ -422,7 +520,8 @@ def create_source( except DoesNotExist: source = cls( name=name, - url=url + url=url, + description=description ) if slug: source.slug = slugify(slug) 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/tests/test_sources.py b/backend/tests/test_sources.py index 09d18e4bb..6400bdd12 100644 --- a/backend/tests/test_sources.py +++ b/backend/tests/test_sources.py @@ -3,7 +3,7 @@ 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 +271,69 @@ 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 res.json["contributions_over_time"] == [ + {"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"] From 9f7b5cd4d0c8071f0ab69a415910691c7161d515 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sat, 18 Apr 2026 15:06:22 -0400 Subject: [PATCH 03/11] Update Source Profile Pages - Add edit page for source profiles - Update source profile page to match new design and include additional information - Add activity card (Simple SVG) --- .../app/sources/[identifier]/edit/page.tsx | 263 ++++++++++++++++++ frontend/app/sources/[identifier]/page.tsx | 69 ++++- frontend/components/Profile/ActivityCard.tsx | 217 +++++++++++++-- frontend/components/Profile/ContactCard.tsx | 81 ++++-- .../Profile/OrganizationMembersCard.tsx | 100 +++---- .../Profile/OrganizationProfile.tsx | 58 ++-- .../components/Profile/ProfileHeaderCard.tsx | 49 +++- frontend/components/Profile/ProfileLayout.tsx | 2 +- .../components/Profile/contactCard.module.css | 53 +++- .../organizationMembersCard.module.css | 43 ++- .../Profile/profileHeaderCard.module.css | 62 ++++- frontend/utils/api.ts | 42 +++ frontend/utils/apiRoutes.ts | 4 +- frontend/utils/useProfile.ts | 87 ++++-- 14 files changed, 944 insertions(+), 186 deletions(-) create mode 100644 frontend/app/sources/[identifier]/edit/page.tsx diff --git a/frontend/app/sources/[identifier]/edit/page.tsx b/frontend/app/sources/[identifier]/edit/page.tsx new file mode 100644 index 000000000..95044a8cb --- /dev/null +++ b/frontend/app/sources/[identifier]/edit/page.tsx @@ -0,0 +1,263 @@ +"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 index 7b252cac0..3aaa865b0 100644 --- a/frontend/app/sources/[identifier]/page.tsx +++ b/frontend/app/sources/[identifier]/page.tsx @@ -1,13 +1,67 @@ "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) + + 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 + + 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) + }) + }, [accessToken, organization?.uid]) if (loading) { return
Loading organization...
@@ -17,5 +71,18 @@ export default function SourcePage() { return
Organization not found.
} - return + const canEdit = + !!currentUser?.uid && + organization.memberships?.some( + (membership) => membership.uid === currentUser.uid && membership.role === "Administrator" + ) === true + + return ( + + ) } diff --git a/frontend/components/Profile/ActivityCard.tsx b/frontend/components/Profile/ActivityCard.tsx index f1fdbb366..c19603d20 100644 --- a/frontend/components/Profile/ActivityCard.tsx +++ b/frontend/components/Profile/ActivityCard.tsx @@ -1,33 +1,216 @@ +"use client" + import React from "react" import { Card, CardContent, Typography } from "@mui/material" +import { SourceActivity } from "@/utils/api" + +const CHART_WIDTH = 500 +const CHART_HEIGHT = 260 +const PADDING_X = 42 +const PADDING_Y = 18 + +const PIE_COLORS = ["#D500F9", "#2196F3", "#26A69A", "#FB8C00", "#8E24AA", "#546E7A"] + +const formatDateLabel = (date: string) => + 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 LineChart({ data }: { data: SourceActivity["contributions_over_time"] }) { + if (!data.length) { + return No contributions recorded yet. + } + + const maxValue = Math.max(...data.map((point) => point.count), 1) + const chartInnerWidth = CHART_WIDTH - PADDING_X * 2 + const chartInnerHeight = CHART_HEIGHT - PADDING_Y * 2 + + const points = data.map((point, index) => { + const x = + PADDING_X + (data.length === 1 ? chartInnerWidth / 2 : (index / (data.length - 1)) * chartInnerWidth) + const y = + PADDING_Y + chartInnerHeight - (point.count / maxValue) * chartInnerHeight + + return { ...point, x, y } + }) + + const path = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ") + const ticks = Array.from({ length: 5 }, (_, index) => Math.round((maxValue * (4 - index)) / 4)) + + return ( + + {ticks.map((tick) => { + const y = PADDING_Y + chartInnerHeight - (tick / maxValue) * chartInnerHeight + return ( + + + + {tick} + + + ) + })} + + + + + -export default function ActivityCard() { + {points.map((point) => ( + + + + {formatDateLabel(point.date)} + + + ))} + + ) +} + +function polarToCartesian(cx: number, cy: number, r: number, angle: number) { + const radians = ((angle - 90) * Math.PI) / 180 + return { + x: cx + r * Math.cos(radians), + y: cy + r * Math.sin(radians) + } +} + +function PieChart({ data }: { data: SourceActivity["contribution_locations"] }) { + if (!data.length) { + return No location data available yet. + } + + const total = data.reduce((sum, slice) => sum + slice.count, 0) + const radius = 88 + const center = 110 + let startAngle = 0 + + return ( +
+ + {data.map((slice, index) => { + const sweep = (slice.count / total) * 360 + const endAngle = startAngle + sweep + const largeArcFlag = sweep > 180 ? 1 : 0 + const start = polarToCartesian(center, center, radius, startAngle) + const end = polarToCartesian(center, center, radius, endAngle) + const path = [ + `M ${center} ${center}`, + `L ${start.x} ${start.y}`, + `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`, + "Z" + ].join(" ") + const fill = PIE_COLORS[index % PIE_COLORS.length] + startAngle = endAngle + + return + })} + + +
+ {data.map((slice, index) => ( +
+ + + {slice.label} ({slice.count}) + +
+ ))} +
+
+ ) +} + +export default function ActivityCard({ activity }: { activity: SourceActivity | null }) { return ( - + - - Activity - - - Last active Oct 24, 2025 - - {/* TODO: Add contribution history and location data viz */} +
+ + Activity + + + {formatLastActive(activity?.last_active_at || null)} + +
+ +
+
+ Contribution History + +
+ +
+ Locations of Contributions + +
+
) 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/OrganizationMembersCard.tsx b/frontend/components/Profile/OrganizationMembersCard.tsx index 1aff2b02a..2ffec8e50 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}

-
diff --git a/frontend/components/Profile/OrganizationProfile.tsx b/frontend/components/Profile/OrganizationProfile.tsx index d5d3d4c91..4bd22fade 100644 --- a/frontend/components/Profile/OrganizationProfile.tsx +++ b/frontend/components/Profile/OrganizationProfile.tsx @@ -1,39 +1,26 @@ "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 +}: { + organization: Organization + members: SourceMember[] + canEdit?: boolean + activity?: SourceActivity | null +}) { return ( - - - - - } - > + - - + +
) diff --git a/frontend/components/Profile/ProfileHeaderCard.tsx b/frontend/components/Profile/ProfileHeaderCard.tsx index 5797f0f11..f6e3e6f82 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/contactCard.module.css b/frontend/components/Profile/contactCard.module.css index 20a0ca9a0..37d12f291 100644 --- a/frontend/components/Profile/contactCard.module.css +++ b/frontend/components/Profile/contactCard.module.css @@ -1,43 +1,84 @@ .editIcon { - float: right; + position: absolute; + top: 24px; + right: 24px; cursor: pointer; } +.editButton { + position: absolute !important; + top: 24px; + right: 24px; + min-width: 163px !important; + min-height: 30px !important; + padding: 4px 5px !important; + border-radius: 4px !important; + font-family: Roboto, sans-serif !important; + font-size: 13px !important; + font-weight: 500 !important; + letter-spacing: 0.46px !important; + text-transform: uppercase !important; +} + .contactWrapper { display: flex; flex-direction: column; - gap: 16px; - margin-top: 16px; + gap: 24px; + margin-top: 0; } .contactRow { display: flex; - gap: 12px; + gap: 16px; + align-items: flex-start; } .contactDetails { display: flex; flex-direction: column; + gap: 8px; } .contactLabel { + margin: 0; + font-family: Inter, Roboto, sans-serif; font-weight: 500; font-size: 20px; + line-height: 24px; + color: #000; } .socials { display: flex; - gap: 16px; - margin-top: 8px; + gap: 40px; + margin-top: 0; + align-items: center; } .emailLink, .websiteLink { text-decoration: none; word-break: break-all; + font-family: Inter, Roboto, sans-serif; + font-size: 20px; + line-height: 24px; + color: #1e1e1e; } .emailLink:hover, .websiteLink:hover { text-decoration: underline; } + +@media screen and (max-width: 430px) { + .editButton, + .editIcon { + position: static !important; + align-self: flex-end; + } + + .socials { + gap: 24px; + flex-wrap: wrap; + } +} diff --git a/frontend/components/Profile/organizationMembersCard.module.css b/frontend/components/Profile/organizationMembersCard.module.css index a72b90cba..f9940376a 100644 --- a/frontend/components/Profile/organizationMembersCard.module.css +++ b/frontend/components/Profile/organizationMembersCard.module.css @@ -1,14 +1,32 @@ .name { + margin: 0; + font-family: Inter, Roboto, sans-serif; font-weight: 500; font-size: 20px; + line-height: 24px; + text-align: center; + color: #000; } .memberItem { display: flex; flex-direction: column; align-items: center; - gap: 4px; + justify-content: center; + gap: 12px; padding: 8px; + width: 216px; + min-height: 216px; + border-radius: 8px; +} + +.memberItem p { + margin: 0; + font-family: Inter, Roboto, sans-serif; + font-size: 16px; + line-height: 19px; + text-align: center; + color: #000; } .membersGrid { @@ -18,3 +36,26 @@ justify-content: center; margin-top: 24px; } + +.viewButton { + width: 160px; + height: 30px; + padding: 4px 10px !important; + border-radius: 4px !important; + font-family: Roboto, sans-serif !important; + font-size: 13px !important; + font-weight: 500 !important; + letter-spacing: 0.46px !important; + text-transform: uppercase !important; +} + +@media screen and (max-width: 745px) { + .memberItem { + width: 100%; + max-width: 320px; + } + + .viewButton { + width: 100%; + } +} diff --git a/frontend/components/Profile/profileHeaderCard.module.css b/frontend/components/Profile/profileHeaderCard.module.css index 365d94d81..11d40a5a4 100644 --- a/frontend/components/Profile/profileHeaderCard.module.css +++ b/frontend/components/Profile/profileHeaderCard.module.css @@ -1,6 +1,6 @@ .container { display: flex; - gap: 16px; + gap: 54px; align-items: center; } @@ -8,6 +8,7 @@ display: flex; flex-direction: column; gap: 16px; + align-items: flex-start; } .meta { @@ -17,7 +18,8 @@ } .bio { - margin-top: 24px; + margin-top: 32px; + width: 100%; } .actions { @@ -31,21 +33,69 @@ } .editIcon { - float: right; + position: absolute; + top: 24px; + right: 24px; cursor: pointer; - margin-left: 20px; +} + +.editButton { + position: absolute !important; + top: 24px; + right: 24px; + min-width: 178px !important; + min-height: 30px !important; + padding: 4px 5px !important; + border-radius: 4px !important; + font-family: Roboto, sans-serif !important; + font-size: 13px !important; + font-weight: 500 !important; + letter-spacing: 0.46px !important; + text-transform: uppercase !important; +} + +.name { + font-family: Inter, Roboto, sans-serif !important; + font-size: 24px !important; + line-height: 29px !important; + font-weight: 600 !important; + color: #000 !important; +} + +.metaText { + font-family: Inter, Roboto, sans-serif !important; + font-size: 20px !important; + line-height: 24px !important; + font-weight: 400 !important; + color: #000 !important; +} + +.bioText { + margin: 0; + font-family: Inter, Roboto, sans-serif; + font-size: 20px; + line-height: 24px; + color: #000; } @media screen and (max-width: 430px) { .container { flex-direction: column; + gap: 24px; + align-items: flex-start; } .info { - align-items: center; + align-items: flex-start; } .meta { - align-items: center; + align-items: flex-start; + } + + .editButton, + .editIcon { + position: static !important; + align-self: flex-end; } } diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts index 362c321fb..34e9b5940 100644 --- a/frontend/utils/api.ts +++ b/frontend/utils/api.ts @@ -9,6 +9,20 @@ export interface Source { slug?: string } +export type SourceMember = { + uid: string + first_name: string + last_name: string + title?: string + organization?: string + profile_image?: string +} + +export type SourceMembership = { + uid: string + role?: string +} + export interface Perpetrator { first_name?: string last_name?: string @@ -147,11 +161,39 @@ export type Organization = { logo: string website: string email: string + social_media?: SocialMedia location?: { city?: string state?: string } type_of_service: string + memberships?: SourceMembership[] +} + +export type UpdateOrganizationPayload = { + name?: string + contact_email?: string + url?: string + slug?: string + description?: string + social_media?: SocialMedia +} + +export type SourceActivityPoint = { + date: string + count: number +} + +export type SourceActivityLocation = { + label: string + count: number +} + +export type SourceActivity = { + last_active_at: string | null + total_changes: number + contributions_over_time: SourceActivityPoint[] + contribution_locations: SourceActivityLocation[] } export type StateID = { diff --git a/frontend/utils/apiRoutes.ts b/frontend/utils/apiRoutes.ts index 81892bd76..d992e8083 100644 --- a/frontend/utils/apiRoutes.ts +++ b/frontend/utils/apiRoutes.ts @@ -20,7 +20,9 @@ const API_ROUTES = { sources: { all: "/sources", profile: (identifier: string) => `/sources/${identifier}`, - profileBySlug: (slug: string) => `/sources/slug/${slug}` + profileBySlug: (slug: string) => `/sources/slug/${slug}`, + members: (uid: string) => `/sources/${uid}/members`, + activity: (uid: string) => `/sources/${uid}/activity` }, officers: { profile: (slug: string) => `/officers/${slug}` diff --git a/frontend/utils/useProfile.ts b/frontend/utils/useProfile.ts index eaa87c797..c80e6dcf8 100644 --- a/frontend/utils/useProfile.ts +++ b/frontend/utils/useProfile.ts @@ -3,7 +3,13 @@ import { useRouter } from "next/navigation" import { useAuth } from "@/providers/AuthProvider" import { apiFetch } from "@/utils/apiFetch" import API_ROUTES, { apiBaseUrl } from "@/utils/apiRoutes" -import { UserProfile, UpdateUserProfilePayload, Organization } from "./api" +import { + UserProfile, + UpdateUserProfilePayload, + Organization, + SocialMedia, + SourceMembership +} from "./api" // Helper function to generate slug from name const generateSlug = (firstName: string, lastName?: string): string => { @@ -17,6 +23,61 @@ const generateSlug = (firstName: string, lastName?: string): string => { type ProfileType = "user" | "organization" +const extractPrimaryEmail = (source: Record) => { + const primaryEmail = source.primary_email + + if (typeof source.contact_email === "string") return source.contact_email + + if (Array.isArray(primaryEmail) && primaryEmail[0] && typeof primaryEmail[0] === "object") { + const email = (primaryEmail[0] as { email?: string }).email + return email || "" + } + + return "" +} + +const extractSocialMedia = (source: Record): SocialMedia | undefined => { + const socialMedia = source.social_media + + if (!Array.isArray(socialMedia) || !socialMedia[0] || typeof socialMedia[0] !== "object") { + return undefined + } + + const node = socialMedia[0] as SocialMedia + return { + twitter_url: node.twitter_url, + facebook_url: node.facebook_url, + linkedin_url: node.linkedin_url, + instagram_url: node.instagram_url, + youtube_url: node.youtube_url, + tiktok_url: node.tiktok_url + } +} + +const extractMemberships = (source: Record): SourceMembership[] => { + const members = source.members + + if (!Array.isArray(members)) { + return [] + } + + return members.flatMap((member) => { + if (!member || typeof member !== "object") return [] + + const node = (member as { node?: { uid?: string } }).node + const relationship = (member as { relationship?: { role?: string } }).relationship + + if (!node?.uid) return [] + + return [ + { + uid: node.uid, + role: relationship?.role + } + ] + }) +} + export const useProfile = ( type: ProfileType, identifier?: string @@ -81,24 +142,6 @@ export const useProfile = ( setProfile(matchedProfile as T) } else if (type === "organization") { - // Fetch all organizations/sources - const res = await apiFetch(`${apiBaseUrl}${API_ROUTES.sources.all}`, { - headers: { - Authorization: `Bearer ${accessToken}` - } - }) - - if (!res.ok) { - if (res.status === 401) { - router.push("/login") - return - } - throw new Error("Failed to fetch organizations") - } - - const data = await res.json() - const sources = data.results || data - if (!identifier) { setProfile(null) setLoading(false) @@ -150,12 +193,14 @@ export const useProfile = ( description: matchedSource.description || "", logo: matchedSource.logo || "/broken-image.jpg", website: matchedSource.url || "", - email: matchedSource.contact_email || "", + email: extractPrimaryEmail(matchedSource), + social_media: extractSocialMedia(matchedSource), location: { city: matchedSource.city || "", state: matchedSource.state || "" }, - type_of_service: matchedSource.type_of_service || "Organization" + type_of_service: matchedSource.type_of_service || "Organization", + memberships: extractMemberships(matchedSource) } setProfile(organization as T) From 6d8ccc74d2358f14c10d7ca8ee5930c514649c33 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sun, 19 Apr 2026 11:02:29 -0400 Subject: [PATCH 04/11] Implement x-charts --- backend/database/models/source.py | 20 +- frontend/app/sources/[identifier]/page.tsx | 4 + frontend/components/Profile/ActivityCard.tsx | 240 +++++----- .../Profile/OrganizationProfile.tsx | 6 +- frontend/package-lock.json | 429 +++++++++++++++++- frontend/package.json | 1 + 6 files changed, 559 insertions(+), 141 deletions(-) diff --git a/backend/database/models/source.py b/backend/database/models/source.py index 1a3ef3327..ab420d812 100644 --- a/backend/database/models/source.py +++ b/backend/database/models/source.py @@ -356,6 +356,12 @@ def __repr__(self): 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, @@ -371,10 +377,16 @@ def get_activity_summary(self, top_locations: int = 5) -> dict: """ rows, _ = db.cypher_query(time_query, {"uid": self.uid}) - contributions_over_time = [ - {"date": row[0], "count": row[1]} - for row in rows - ] + 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 = """ diff --git a/frontend/app/sources/[identifier]/page.tsx b/frontend/app/sources/[identifier]/page.tsx index 3aaa865b0..fc2f697d0 100644 --- a/frontend/app/sources/[identifier]/page.tsx +++ b/frontend/app/sources/[identifier]/page.tsx @@ -18,6 +18,7 @@ export default function SourcePage() { const { accessToken } = useAuth() const [members, setMembers] = useState([]) const [activity, setActivity] = useState(null) + const [activityLoading, setActivityLoading] = useState(false) useEffect(() => { if (!accessToken || !organization?.uid) return @@ -44,6 +45,7 @@ export default function SourcePage() { useEffect(() => { if (!accessToken || !organization?.uid) return + setActivityLoading(true) apiFetch(`${apiBaseUrl}${API_ROUTES.sources.activity(organization.uid)}`, { headers: { Authorization: `Bearer ${accessToken}` @@ -61,6 +63,7 @@ export default function SourcePage() { console.error(error) setActivity(null) }) + .finally(() => setActivityLoading(false)) }, [accessToken, organization?.uid]) if (loading) { @@ -83,6 +86,7 @@ export default function SourcePage() { members={members} canEdit={canEdit} activity={activity} + activityLoading={activityLoading} /> ) } diff --git a/frontend/components/Profile/ActivityCard.tsx b/frontend/components/Profile/ActivityCard.tsx index c19603d20..446060216 100644 --- a/frontend/components/Profile/ActivityCard.tsx +++ b/frontend/components/Profile/ActivityCard.tsx @@ -1,14 +1,10 @@ "use client" import React from "react" -import { Card, CardContent, Typography } from "@mui/material" +import { Box, Card, CardContent, CircularProgress, Typography } from "@mui/material" +import { BarChart, PieChart } from "@mui/x-charts" import { SourceActivity } from "@/utils/api" -const CHART_WIDTH = 500 -const CHART_HEIGHT = 260 -const PADDING_X = 42 -const PADDING_Y = 18 - const PIE_COLORS = ["#D500F9", "#2196F3", "#26A69A", "#FB8C00", "#8E24AA", "#546E7A"] const formatDateLabel = (date: string) => @@ -27,142 +23,102 @@ const formatLastActive = (timestamp: string | null) => { })}` } -function LineChart({ data }: { data: SourceActivity["contributions_over_time"] }) { - if (!data.length) { - return No contributions recorded yet. - } - - const maxValue = Math.max(...data.map((point) => point.count), 1) - const chartInnerWidth = CHART_WIDTH - PADDING_X * 2 - const chartInnerHeight = CHART_HEIGHT - PADDING_Y * 2 - - const points = data.map((point, index) => { - const x = - PADDING_X + (data.length === 1 ? chartInnerWidth / 2 : (index / (data.length - 1)) * chartInnerWidth) - const y = - PADDING_Y + chartInnerHeight - (point.count / maxValue) * chartInnerHeight - - return { ...point, x, y } - }) - - const path = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ") - const ticks = Array.from({ length: 5 }, (_, index) => Math.round((maxValue * (4 - index)) / 4)) - +function ContributionHistoryChart({ data }: { data: SourceActivity["contributions_over_time"] }) { return ( - - {ticks.map((tick) => { - const y = PADDING_Y + chartInnerHeight - (tick / maxValue) * chartInnerHeight - return ( - - - - {tick} - - - ) - })} - - - - - - - {points.map((point) => ( - - - - {formatDateLabel(point.date)} - - - ))} - + 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 polarToCartesian(cx: number, cy: number, r: number, angle: number) { - const radians = ((angle - 90) * Math.PI) / 180 - return { - x: cx + r * Math.cos(radians), - y: cy + r * Math.sin(radians) - } -} - -function PieChart({ data }: { data: SourceActivity["contribution_locations"] }) { +function ContributionLocationChart({ data }: { data: SourceActivity["contribution_locations"] }) { if (!data.length) { return No location data available yet. } - const total = data.reduce((sum, slice) => sum + slice.count, 0) - const radius = 88 - const center = 110 - let startAngle = 0 - return ( -
- - {data.map((slice, index) => { - const sweep = (slice.count / total) * 360 - const endAngle = startAngle + sweep - const largeArcFlag = sweep > 180 ? 1 : 0 - const start = polarToCartesian(center, center, radius, startAngle) - const end = polarToCartesian(center, center, radius, endAngle) - const path = [ - `M ${center} ${center}`, - `L ${start.x} ${start.y}`, - `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`, - "Z" - ].join(" ") - const fill = PIE_COLORS[index % PIE_COLORS.length] - startAngle = endAngle - - return - })} - - -
- {data.map((slice, index) => ( -
- - - {slice.label} ({slice.count}) - -
- ))} -
-
+ ({ + 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 }: { activity: SourceActivity | null }) { +export default function ActivityCard({ + activity, + loading = false +}: { + activity: SourceActivity | null + loading?: boolean +}) { return (
Contribution History - + {loading ? ( + + + + ) : ( + + )}
Locations of Contributions - + {loading ? ( + + + + ) : ( + + )}
diff --git a/frontend/components/Profile/OrganizationProfile.tsx b/frontend/components/Profile/OrganizationProfile.tsx index 4bd22fade..dd3a49cca 100644 --- a/frontend/components/Profile/OrganizationProfile.tsx +++ b/frontend/components/Profile/OrganizationProfile.tsx @@ -12,12 +12,14 @@ export default function OrganizationProfile({ organization, members, canEdit = false, - activity + activity, + activityLoading = false }: { organization: Organization members: SourceMember[] canEdit?: boolean activity?: SourceActivity | null + activityLoading?: boolean }) { return ( @@ -49,7 +51,7 @@ export default function OrganizationProfile({ canEdit={canEdit} editHref={organization.uid ? `/sources/${organization.uid}/edit` : undefined} /> - +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49ae7d275..fc00427aa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@mui/lab": "^7.0.1-beta.21", "@mui/material": "^7.3.7", "@mui/styled-engine-sc": "^6.4.3", + "@mui/x-charts": "^9.0.2", "env-cmd": "^10.1.0", "maplibre-gl": "^5.20.0", "next": "15.3.3", @@ -339,9 +340,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2199,6 +2200,211 @@ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, + "node_modules/@mui/x-charts": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.2.tgz", + "integrity": "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "9.0.0", + "@mui/x-charts-vendor": "^9.0.0", + "@mui/x-internal-gestures": "^9.0.2", + "@mui/x-internals": "^9.0.0", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", + "integrity": "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@types/d3-array": "^3.2.2", + "@types/d3-color": "^3.1.3", + "@types/d3-format": "^3.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.1", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", + "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-format": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", + "d3-timer": "^3.0.1", + "flatqueue": "^3.0.0", + "internmap": "^2.0.3" + } + }, + "node_modules/@mui/x-charts/node_modules/@mui/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", + "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts/node_modules/@mui/utils": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", + "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts/node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, + "node_modules/@mui/x-internal-gestures": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.2.tgz", + "integrity": "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + } + }, + "node_modules/@mui/x-internals": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", + "integrity": "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "9.0.0", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", + "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", + "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "15.3.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.3.tgz", @@ -2876,6 +3082,75 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3733,6 +4008,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4105,6 +4386,118 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5215,6 +5608,12 @@ "node": ">=16" } }, + "node_modules/flatqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", + "integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==", + "license": "ISC" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -5663,6 +6062,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7169,6 +7577,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -8373,6 +8787,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 975a00fb1..f926402bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@mui/lab": "^7.0.1-beta.21", "@mui/material": "^7.3.7", "@mui/styled-engine-sc": "^6.4.3", + "@mui/x-charts": "^9.0.2", "env-cmd": "^10.1.0", "maplibre-gl": "^5.20.0", "next": "15.3.3", From 32f8e2b48a4c7ad8f8bd6ea081daf43d4cfb01c2 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sun, 19 Apr 2026 12:38:21 -0400 Subject: [PATCH 05/11] Refactor /users routes --- backend/dto/user_profile.py | 45 +++++ backend/routes/users.py | 295 ++++++++----------------------- backend/services/user_service.py | 210 ++++++++++++++++++++++ backend/tests/test_sources.py | 7 +- backend/tests/test_users.py | 140 +++++++++++++++ 5 files changed, 473 insertions(+), 224 deletions(-) create mode 100644 backend/dto/user_profile.py create mode 100644 backend/services/user_service.py create mode 100644 backend/tests/test_users.py diff --git a/backend/dto/user_profile.py b/backend/dto/user_profile.py new file mode 100644 index 000000000..6d1671dc9 --- /dev/null +++ b/backend/dto/user_profile.py @@ -0,0 +1,45 @@ +from pydantic import 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 diff --git a/backend/routes/users.py b/backend/routes/users.py index eb16bde2c..c567227e0 100644 --- a/backend/routes/users.py +++ b/backend/routes/users.py @@ -1,253 +1,102 @@ -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 UpdateCurrentUser +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() 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, - } - - return jsonify(payload), 200 - - -@bp.route("/self", methods=["PATCH", "OPTIONS"]) -@cross_origin() + response = user_service.get_user_profile(jwt_decoded["sub"]) + return ordered_jsonify(response), 200 + except LookupError as e: + return _not_found_response(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.""" + try: + response = user_service.get_user_profile(user_uid) + return ordered_jsonify(response), 200 + except LookupError as e: + return _not_found_response(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..26fdd803e --- /dev/null +++ b/backend/services/user_service.py @@ -0,0 +1,210 @@ +import os +import uuid +from urllib.parse import urlparse + +import boto3 +from flask import jsonify, send_from_directory +from werkzeug.utils import secure_filename + +from backend.database import ( + EmailContact, + PhoneContact, + SocialMediaContact, + 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) -> 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) + + 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_user_profile(user: User) -> 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), + } + + return { + "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, + } diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py index 6400bdd12..c38d3d90f 100644 --- a/backend/tests/test_sources.py +++ b/backend/tests/test_sources.py @@ -323,7 +323,12 @@ def test_get_source_activity(client, example_source, example_user, access_token) assert res.status_code == 200 assert res.json["total_changes"] == 4 assert res.json["last_active_at"].startswith("2026-03-20") - assert res.json["contributions_over_time"] == [ + 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}, diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 000000000..3d7b062ed --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,140 @@ +from backend.database import ( + 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" + + +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_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", + } From f569e2b953f91a2f4ed119aec0db0763b164e4ee Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sun, 19 Apr 2026 13:05:14 -0400 Subject: [PATCH 06/11] Add "Peole You Know" to backend --- backend/dto/user_profile.py | 29 ++++- backend/routes/users.py | 51 ++++++++- backend/services/user_service.py | 111 +++++++++++++++++- backend/tests/test_users.py | 189 +++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 8 deletions(-) diff --git a/backend/dto/user_profile.py b/backend/dto/user_profile.py index 6d1671dc9..e7528d76f 100644 --- a/backend/dto/user_profile.py +++ b/backend/dto/user_profile.py @@ -1,4 +1,4 @@ -from pydantic import field_validator +from pydantic import Field, field_validator from backend.dto.common import RequestDTO from backend.dto.common_filters import validate_state_code @@ -43,3 +43,30 @@ class UpdateCurrentUser(RequestDTO): 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/users.py b/backend/routes/users.py index c567227e0..7208d48f2 100644 --- a/backend/routes/users.py +++ b/backend/routes/users.py @@ -4,7 +4,11 @@ from backend.auth.jwt import min_role_required from backend.database.models.user import UserRole -from backend.dto.user_profile import UpdateCurrentUser +from backend.dto.user_profile import ( + GetUserParams, + UpdateCurrentUser, + UserSuggestionParams, +) from backend.schemas import ordered_jsonify, validate_request from backend.services.user_service import UserService @@ -23,12 +27,22 @@ def _not_found_response(message: str): def get_current_user(): """Return the currently authenticated user's full profile.""" jwt_decoded = get_jwt() + raw = { + **request.args, + "include": request.args.getlist("include"), + } try: - response = user_service.get_user_profile(jwt_decoded["sub"]) + 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"]) @@ -36,11 +50,42 @@ def get_current_user(): @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"), + } + try: - response = user_service.get_user_profile(user_uid) + 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/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"]) diff --git a/backend/services/user_service.py b/backend/services/user_service.py index 26fdd803e..ccddccc43 100644 --- a/backend/services/user_service.py +++ b/backend/services/user_service.py @@ -4,12 +4,14 @@ 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 @@ -18,11 +20,72 @@ class UserService: allowed_profile_photo_extensions = {".jpg", ".jpeg", ".png", ".gif"} - def get_user_profile(self, user_uid: str) -> dict: + 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) + 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, @@ -169,7 +232,42 @@ def get_profile_photo(self, user_uid: str): return jsonify({"profile_image_url": url}), 200 @staticmethod - def serialize_user_profile(user: User) -> dict: + def _serialize_memberships(user: User) -> list[dict]: + query = """ + MATCH (u:User {uid: $user_uid})-[membership:IS_MEMBER]->(s:Source) + RETURN s, membership + ORDER BY membership.date_joined DESC, s.name ASC + """ + rows, _ = db.cypher_query(query, {"user_uid": user.uid}) + + memberships = [] + for source_node, relationship 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": getattr(relationship, "role", None), + "date_joined": ( + relationship.date_joined.isoformat() + if getattr(relationship, "date_joined", None) is not None + else None + ), + "is_active": getattr(relationship, "is_active", None), + }) + + 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()] @@ -186,7 +284,7 @@ def serialize_user_profile(user: User) -> dict: "tiktok_url": getattr(sm, "tiktok_url", None), } - return { + profile = { "uid": user.uid, "first_name": user.first_name, "last_name": user.last_name, @@ -208,3 +306,8 @@ def serialize_user_profile(user: User) -> dict: "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_users.py b/backend/tests/test_users.py index 3d7b062ed..023c6026b 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -1,4 +1,6 @@ from backend.database import ( + MemberRole, + Source, User, EmailContact, PhoneContact, @@ -48,6 +50,7 @@ def test_get_user_by_uid(client, access_token): 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): @@ -60,6 +63,192 @@ def test_get_user_by_uid_not_found(client, access_token): 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, From ebe34e648a919354394a61418c7b6b343774b3d5 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sun, 19 Apr 2026 13:25:03 -0400 Subject: [PATCH 07/11] Update User Profile page --- frontend/app/profile/[uid]/page.tsx | 77 ++++++++++++ frontend/app/profile/contact/edit/page.tsx | 8 +- frontend/app/profile/edit/page.tsx | 8 +- frontend/app/profile/page.tsx | 72 ++--------- .../components/Profile/OrganizationCard.tsx | 82 ++++++++---- .../Profile/OrganizationMembersCard.tsx | 8 +- .../components/Profile/SuggestionsCard.tsx | 18 ++- .../Profile/organizationCard.module.css | 9 ++ .../Profile/suggestionsCard.module.css | 9 ++ frontend/utils/api.ts | 35 ++++++ frontend/utils/apiRoutes.ts | 4 +- frontend/utils/useProfile.ts | 119 +++++++++++------- 12 files changed, 314 insertions(+), 135 deletions(-) create mode 100644 frontend/app/profile/[uid]/page.tsx diff --git a/frontend/app/profile/[uid]/page.tsx b/frontend/app/profile/[uid]/page.tsx new file mode 100644 index 000000000..00de0ea1a --- /dev/null +++ b/frontend/app/profile/[uid]/page.tsx @@ -0,0 +1,77 @@ +"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) + + 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 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" } + ] + + const socialMediaContacts = profile.social_media || ({} as SocialMedia) + + return ( + + + + + } + > + + + +
+ + ) +} 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/components/Profile/OrganizationCard.tsx b/frontend/components/Profile/OrganizationCard.tsx index 456d82834..793b780b2 100644 --- a/frontend/components/Profile/OrganizationCard.tsx +++ b/frontend/components/Profile/OrganizationCard.tsx @@ -1,8 +1,31 @@ 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 2ffec8e50..c1ae7c644 100644 --- a/frontend/components/Profile/OrganizationMembersCard.tsx +++ b/frontend/components/Profile/OrganizationMembersCard.tsx @@ -59,7 +59,13 @@ export default function OrganizationMembers({ members }: { members: SourceMember

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

{item.title ?

{item.title}

: null} {item.organization ?

{item.organization}

: 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}

-
-
- Contribution History +
+
+ + Contribution History + {loading ? ( - + -
- Locations of Contributions +
+ + Locations of Contributions + {loading ? ( - + { }) } -export default function OrganizationCard({ - memberships -}: { - memberships?: UserMembership[] -}) { +export default function OrganizationCard({ memberships }: { memberships?: UserMembership[] }) { if (!memberships?.length) { return null } @@ -53,13 +49,21 @@ export default function OrganizationCard({
diff --git a/frontend/components/Profile/ProfileHeaderCard.tsx b/frontend/components/Profile/ProfileHeaderCard.tsx index f6e3e6f82..512d0db29 100644 --- a/frontend/components/Profile/ProfileHeaderCard.tsx +++ b/frontend/components/Profile/ProfileHeaderCard.tsx @@ -110,7 +110,9 @@ export default function ProfileHeaderCard({
)} - {showFollowerStats ?
50 followers • 30 following
: null} + {showFollowerStats ? ( +
50 followers • 30 following
+ ) : null}
From 31a7a341a9913d0551bb8d4cb77af0777ae9cc2b Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sun, 19 Apr 2026 14:06:21 -0400 Subject: [PATCH 10/11] Update styles --- frontend/app/profile/[uid]/page.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/app/profile/[uid]/page.tsx b/frontend/app/profile/[uid]/page.tsx index 5b94ed7cf..a46bf1fd3 100644 --- a/frontend/app/profile/[uid]/page.tsx +++ b/frontend/app/profile/[uid]/page.tsx @@ -15,6 +15,7 @@ export default function UserProfilePage() { 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.

@@ -22,7 +23,7 @@ export default function UserProfilePage() { 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", + avatarUrl: person.profile_image_url || "/broken-image.jpg", href: `/profile/${person.uid}` })) @@ -31,22 +32,26 @@ export default function UserProfilePage() { return ( - - + !canEdit ? ( + <> + + + ) : undefined } >
From 562cc24046f009dadbf128b1b9d0b65f9e142652 Mon Sep 17 00:00:00 2001 From: "Darrell K Malone Jr." Date: Sun, 19 Apr 2026 14:24:01 -0400 Subject: [PATCH 11/11] Fix build error --- frontend/app/profile/[uid]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/profile/[uid]/page.tsx b/frontend/app/profile/[uid]/page.tsx index a46bf1fd3..827ccd042 100644 --- a/frontend/app/profile/[uid]/page.tsx +++ b/frontend/app/profile/[uid]/page.tsx @@ -23,7 +23,7 @@ export default function UserProfilePage() { const peopleSuggestions = suggestions.map((person) => ({ name: `${person.first_name} ${person.last_name}`.trim(), title: person.title || person.organization || "", - avatarUrl: person.profile_image_url || "/broken-image.jpg", + avatarUrl: person.profile_image || "/broken-image.jpg", href: `/profile/${person.uid}` })) @@ -42,7 +42,7 @@ export default function UserProfilePage() {