diff --git a/.github/scripts/src/db/migrate.ts b/.github/scripts/src/db/migrate.ts index a3255601..41dc5e5c 100644 --- a/.github/scripts/src/db/migrate.ts +++ b/.github/scripts/src/db/migrate.ts @@ -26,7 +26,7 @@ async function main() { DATABASE_PORT, DATABASE_USER, DATABASE_PASSWORD, - })`./mvnw flyway:migrate -Dflyway.locations=filesystem:db`; + })`./mvnw flyway:migrate -Dflyway.locations=filesystem:db/migration`; } function parseCiEnv(environment: string) { diff --git a/Justfile b/Justfile index b29dda3a..bcf79693 100644 --- a/Justfile +++ b/Justfile @@ -2,11 +2,11 @@ set shell := ["bash", "-uc"] # Migrate to local DB migrate *args: - dotenvx run -- ./mvnw flyway:migrate -Dflyway.locations=filesystem:./db {{args}} + dotenvx run -- ./mvnw flyway:migrate -Dflyway.locations=filesystem:./db/migration,filesystem:./db/repeated {{args}} # Drop local DB drop *args: - dotenvx run -- ./mvnw flyway:clean -Dflyway.locations=filesystem:./db -Dflyway.cleanDisabled=false {{args}} + dotenvx run -- ./mvnw flyway:clean -Dflyway.locations=filesystem:./db/migration,filesystem:./db/repeated -Dflyway.cleanDisabled=false {{args}} # Run the backend Spring server backend-dev *args: diff --git a/db/README.md b/db/README.md index c0929078..02580b38 100644 --- a/db/README.md +++ b/db/README.md @@ -99,7 +99,7 @@ Versioned migrations are under `db/migration/` - Versioned migrations will only run once. - You can use these migrations to define tables, schemas, columns (otherwise known as DDL) OR define insertions/updates/deletes of certain columns (otherwise known as DML). -> If you need to generate mock data **that does not need to be in production**, please look at the docs on [repeatable migrations](#repeatable-migrations) +> If you need mock data **only for local development**, see [local repeatable migrations](#local-repeatable-migrations). ### Naming Scheme @@ -122,18 +122,17 @@ V0642__Insert_new_tag_enums.SQL V9999__Delete_user_table.SQL ``` -## Repeatable Migrations +## Local Repeatable Migrations -Repeated migrations are under `db/repeated/` +Local repeatable migrations are under `db/repeated/`. > [!NOTE] -> Repeatable migrations are only applied to local & CI databases.
-> Repeatable migrations are **NOT** applied to the production & staging database. +> `just migrate` explicitly includes this directory. Production and staging migrations only scan `db/migration`, so local seed data is never applied to remote databases. ### Explanation 1. Instead of being run just once, repeatable migrations are (re-)applied to a database on [migrate](https://documentation.red-gate.com/fd/migrate-277578887.html) every time their checksum changes. -1. Our main use for repeatable migrations are to generate mock data to use locally and in our CI database, but **is not needed for our production or staging database**. +1. These migrations generate mock data for local development and are excluded from production and staging migrations. #### Requirements diff --git a/db/repeated/R__Mock_V0001_Insert_test_members.sql b/db/repeated/R__Mock_V0001_Insert_test_members.sql new file mode 100644 index 00000000..ccc5a528 --- /dev/null +++ b/db/repeated/R__Mock_V0001_Insert_test_members.sql @@ -0,0 +1,167 @@ +INSERT INTO members ( + id, + first_name, + last_name, + email, + linked_in_url, + introduction, + referral_source, + active, + match_pref, + industry_pref, + role_pref, + topics, + extra_notes +) +VALUES + ( + '10000000-0000-4000-8000-000000000001', + 'Avery', + 'Chen', + 'avery.chen@example.com', + 'https://www.linkedin.com/in/avery-chen-example', + 'Product designer interested in making financial tools easier to use.', + 'Patina Network newsletter', + TRUE, + 'Peer - I want to meet someone with similar experience', + 'Financial Technology', + 'Product Designer', + 'Design systems, Financial literacy, Career growth', + 'Available for coffee in Manhattan or a virtual chat.' + ), + ( + '10000000-0000-4000-8000-000000000002', + 'Jordan', + 'Williams', + 'jordan.williams@example.com', + 'https://www.linkedin.com/in/jordan-williams-example', + 'Backend engineer building reliable systems for healthcare teams.', + 'Friend', + TRUE, + 'Mentor - I am looking for guidance from someone with more experience', + 'Healthcare Technology', + 'Software Engineer', + 'Distributed systems, Leadership, Open source', + 'Usually available after work on Tuesdays and Thursdays.' + ), + ( + '10000000-0000-4000-8000-000000000003', + 'Priya', + 'Patel', + 'priya.patel@example.com', + 'https://www.linkedin.com/in/priya-patel-example', + 'Founder working on software that helps small businesses measure emissions.', + 'Patina Network event', + TRUE, + 'Peer - I want to exchange ideas with another builder', + 'Climate Technology', + 'Founder', + 'Fundraising, Sustainability, Product strategy', + 'Happy to meet virtually or in Brooklyn.' + ), + ( + '10000000-0000-4000-8000-000000000004', + 'Marcus', + 'Johnson', + 'marcus.johnson@example.com', + NULL, + 'Data analyst focused on improving outcomes for public school students.', + 'Colleague', + FALSE, + 'Mentor - I can offer guidance to someone earlier in their career', + 'Education', + 'Data Analyst', + 'Analytics, Public policy, Mentorship', + 'Taking a break from matching this month.' + ), + ( + '10000000-0000-4000-8000-000000000005', + 'Sofia', + 'Rodriguez', + 'sofia.rodriguez@example.com', + 'https://www.linkedin.com/in/sofia-rodriguez-example', + 'Community program director connecting volunteers with neighborhood nonprofits.', + 'Community partner', + TRUE, + 'Open - I am happy to meet a peer, mentor, or mentee', + 'Nonprofit', + 'Program Director', + 'Community building, Social impact, Volunteer engagement', + 'Prefers an in-person conversation when possible.' + ), + ( + '10000000-0000-4000-8000-000000000006', + 'Ethan', + 'Kim', + 'ethan.kim@example.com', + 'https://www.linkedin.com/in/ethan-kim-example', + 'Graduate student researching responsible machine learning applications.', + 'University alumni group', + TRUE, + 'Mentor - I am looking for guidance from someone with more experience', + 'Artificial Intelligence', + 'Graduate Researcher', + 'Responsible AI, Research careers, Entrepreneurship', + NULL + ), + ( + '10000000-0000-4000-8000-000000000007', + 'Nia', + 'Thompson', + 'nia.thompson@example.com', + NULL, + 'Marketing strategist helping independent creators grow sustainable audiences.', + 'LinkedIn', + TRUE, + 'Peer - I want to meet someone with similar experience', + 'Media', + 'Marketing Strategist', + 'Brand strategy, Creator economy, Storytelling', + 'Virtual chats work best during weekday mornings.' + ), + ( + '10000000-0000-4000-8000-000000000008', + 'Leo', + 'Martinez', + 'leo.martinez@example.com', + 'https://www.linkedin.com/in/leo-martinez-example', + 'Operations manager interested in technology-enabled hospitality experiences.', + 'Friend', + TRUE, + 'Open - I am happy to meet a peer, mentor, or mentee', + 'Hospitality', + 'Operations Manager', + 'Operations, Customer experience, Small business', + 'Available in Queens or by video call.' + ), + ( + '10000000-0000-4000-8000-000000000009', + 'Maya', + 'Brooks', + 'maya.brooks@example.com', + 'https://www.linkedin.com/in/maya-brooks-example', + 'People operations leader building inclusive teams at growing companies.', + 'Patina Network event', + TRUE, + 'Mentor - I can offer guidance to someone earlier in their career', + 'Professional Services', + 'People Operations Director', + 'Inclusive hiring, Management, Organizational design', + 'Open to meeting anywhere near a major subway line.' + ), + ( + '10000000-0000-4000-8000-000000000010', + 'Noah', + 'Okafor', + 'noah.okafor@example.com', + NULL, + 'Independent consultant helping local retailers modernize their operations.', + 'Community partner', + FALSE, + 'Peer - I want to exchange ideas with another builder', + 'Retail', + 'Business Consultant', + 'Digital transformation, Consulting, Local business', + 'Inactive seed record for testing member status filters.' + ) +ON CONFLICT (email) DO NOTHING; diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index caed8b67..0d2e9c9e 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -11,6 +11,7 @@ import { EmailHistoryPage } from "@/features/emails/EmailHistoryPage"; import { EmailProgressPage } from "@/features/emails/EmailProgressPage"; import { EmailSendPage } from "@/features/emails/EmailSendPage"; import HomePage from "@/features/home/Home.page"; +import { MembersPage } from "@/features/members/Members.page"; import SamplePage from "@/features/sample/Sample.page"; import SampleAdminPage from "@/features/sample/SampleAdmin.page"; import { SignUpPage } from "@/features/sign-up/SignUp.page"; @@ -61,6 +62,7 @@ export const router = createBrowserRouter([ element: , children: [ { path: "admin", element: }, + { path: "admin/members", element: }, { path: "admin/email", element: , diff --git a/js/src/features/members/Members.page.test.tsx b/js/src/features/members/Members.page.test.tsx new file mode 100644 index 00000000..1c2544a5 --- /dev/null +++ b/js/src/features/members/Members.page.test.tsx @@ -0,0 +1,125 @@ +import { MembersPage } from "@/features/members/Members.page"; +import { + fireEvent, + renderWithProviders, + screen, + within, +} from "@/lib/test/render"; +import { server } from "@/lib/test/server"; +import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { expect, test } from "vitest"; + +test("renders every member returned by the API", async () => { + renderWithProviders(); + + expect(await screen.findByText("Alex Morgan")).toBeInTheDocument(); + expect(screen.getByText("Jordan Lee")).toBeInTheDocument(); + expect(screen.getByText("2 members")).toBeInTheDocument(); + const membersTable = screen.getByRole("table"); + expect(within(membersTable).getByText("Active")).toBeInTheDocument(); + expect(within(membersTable).getByText("Inactive")).toBeInTheDocument(); +}); + +test("renders an empty state when there are no members", async () => { + server.use( + http.get("/api/members", () => + HttpResponse.json({ + message: "Members retrieved successfully", + payload: [], + success: true, + }), + ), + ); + + renderWithProviders(); + + expect(await screen.findByText("No members found.")).toBeInTheDocument(); + expect(screen.getByText("0 members")).toBeInTheDocument(); +}); + +test("applies form filters as API request parameters", async () => { + const user = userEvent.setup(); + const requestedUrls: URL[] = []; + server.use( + http.get("/api/members", ({ request }) => { + requestedUrls.push(new URL(request.url)); + return HttpResponse.json({ + message: "Members retrieved successfully", + payload: [], + success: true, + }); + }), + ); + + renderWithProviders(, { route: "/admin/members" }); + + expect(await screen.findByText("No members found.")).toBeInTheDocument(); + + await user.type(screen.getByLabelText("First name"), " Alex "); + await user.type(screen.getByLabelText("Last name"), "Morgan"); + await user.type(screen.getByLabelText("Email"), "alex@example.com"); + await user.click(screen.getByPlaceholderText("Any status")); + fireEvent.click(screen.getByRole("option", { hidden: true, name: "Active" })); + await user.type(screen.getByLabelText("Match preference"), "Peer"); + await user.type(screen.getByLabelText("Industry"), "Technology"); + await user.type(screen.getByLabelText("Role"), "Engineering"); + await user.type(screen.getByLabelText("Topics"), "Community building"); + await user.click(screen.getByRole("button", { name: "Apply filters" })); + + await waitFor(() => expect(requestedUrls).toHaveLength(2)); + const requestedUrl = requestedUrls[1]; + expect(requestedUrl.searchParams.get("firstName")).toBe("Alex"); + expect(requestedUrl.searchParams.get("lastName")).toBe("Morgan"); + expect(requestedUrl.searchParams.get("email")).toBe("alex@example.com"); + expect(requestedUrl.searchParams.get("active")).toBe("true"); + expect(requestedUrl.searchParams.get("matchPref")).toBe("Peer"); + expect(requestedUrl.searchParams.get("industryPref")).toBe("Technology"); + expect(requestedUrl.searchParams.get("rolePref")).toBe("Engineering"); + expect(requestedUrl.searchParams.get("topics")).toBe("Community building"); +}); + +test("allows status to be deselected and clears it with the other filters", async () => { + const user = userEvent.setup(); + const requestedUrls: URL[] = []; + server.use( + http.get("/api/members", ({ request }) => { + requestedUrls.push(new URL(request.url)); + return HttpResponse.json({ + message: "Members retrieved successfully", + payload: [], + success: true, + }); + }), + ); + + renderWithProviders(); + + expect(await screen.findByText("No members found.")).toBeInTheDocument(); + const statusInput = screen.getByRole("textbox", { name: "Status" }); + await user.click(statusInput); + fireEvent.click(screen.getByRole("option", { hidden: true, name: "Active" })); + expect(statusInput).toHaveValue("Active"); + + await user.click(screen.getByRole("button", { name: "Clear status" })); + expect(statusInput).toHaveValue(""); + await user.type(screen.getByLabelText("First name"), "Alex"); + await user.click(screen.getByRole("button", { name: "Apply filters" })); + + await waitFor(() => expect(requestedUrls).toHaveLength(2)); + expect(requestedUrls[1].searchParams.get("firstName")).toBe("Alex"); + expect(requestedUrls[1].searchParams.has("active")).toBe(false); + + const renderedStatusInput = await screen.findByRole("textbox", { + name: "Status", + }); + await user.click(renderedStatusInput); + fireEvent.click( + screen.getByRole("option", { hidden: true, name: "Inactive" }), + ); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + + expect(renderedStatusInput).toHaveValue(""); + expect(screen.getByLabelText("First name")).toHaveValue(""); +}); diff --git a/js/src/features/members/Members.page.tsx b/js/src/features/members/Members.page.tsx new file mode 100644 index 00000000..b5383fca --- /dev/null +++ b/js/src/features/members/Members.page.tsx @@ -0,0 +1,244 @@ +import { MemberFilters, useMembers } from "@/features/members/api/useMembers"; +import { + Alert, + Anchor, + Badge, + Button, + Center, + Group, + Loader, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { useState } from "react"; +import { z } from "zod"; + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", +}); + +const memberFiltersSchema = z.object({ + active: z.enum(["true", "false"]).nullable(), + email: z.string(), + firstName: z.string(), + industryPref: z.string(), + lastName: z.string(), + matchPref: z.string(), + rolePref: z.string(), + topics: z.string(), +}); + +type MemberFilterValues = z.infer; + +const initialFilterValues: MemberFilterValues = { + active: null, + email: "", + firstName: "", + industryPref: "", + lastName: "", + matchPref: "", + rolePref: "", + topics: "", +}; + +function toMemberFilters(values: MemberFilterValues): MemberFilters { + const normalize = (value: string | null) => value?.trim() || undefined; + + return { + active: normalize(values.active), + email: normalize(values.email), + firstName: normalize(values.firstName), + industryPref: normalize(values.industryPref), + lastName: normalize(values.lastName), + matchPref: normalize(values.matchPref), + rolePref: normalize(values.rolePref), + topics: normalize(values.topics), + }; +} + +export function MembersPage() { + const [filters, setFilters] = useState({}); + const form = useForm({ + initialValues: initialFilterValues, + validate: zodResolver(memberFiltersSchema), + }); + const { data: members, isError, isPending } = useMembers(filters); + + const clearFilters = () => { + form.reset(); + setFilters({}); + }; + + if (isPending) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + Try refreshing the page. If the problem continues, check that the + backend is running. + + ); + } + + return ( + + +
+ Members + Everyone registered for PatChats. +
+ + {members.length} {members.length === 1 ? "member" : "members"} + +
+ +
+ setFilters(toMemberFilters(values)), + )} + > + +
+ Filter members + + Text filters match the full value and are not case-sensitive. + +
+ + + + +