diff --git a/db/README.md b/db/README.md
index 8858f83..3ce1804 100644
--- a/db/README.md
+++ b/db/README.md
@@ -3,54 +3,55 @@
```mermaid
erDiagram
flyway_schema_history {
- integer checksum
- character_varying description "{NOT_NULL}"
- integer execution_time "{NOT_NULL}"
- character_varying installed_by "{NOT_NULL}"
- timestamp_without_time_zone installed_on "{NOT_NULL}"
integer installed_rank PK "{NOT_NULL}"
+ character_varying version
+ character_varying description "{NOT_NULL}"
+ character_varying type "{NOT_NULL}"
character_varying script "{NOT_NULL}"
+ integer checksum
+ character_varying installed_by "{NOT_NULL}"
+ timestamp installed_on "{NOT_NULL}"
+ integer execution_time "{NOT_NULL}"
boolean success "{NOT_NULL}"
- character_varying type "{NOT_NULL}"
- character_varying version
}
match_cycles {
- uuid id PK "{NOT_NULL}"
+ integer id PK "{NOT_NULL}"
text period
- timestamp_with_time_zone run_at "{NOT_NULL}"
- integer total_matched
+ timestamptz run_at "{NOT_NULL}"
integer total_members
- ARRAY unmatched_id
+ integer total_matched
+ integer[] unmatched_id
}
matches {
- timestamp_with_time_zone created_at "{NOT_NULL}"
- uuid cycle_id FK "{NOT_NULL}"
- integer feedback_a
- integer feedback_b
uuid id PK "{NOT_NULL}"
- real match_score
uuid member_a_id FK "{NOT_NULL}"
uuid member_b_id FK "{NOT_NULL}"
+ integer cycle_id FK "{NOT_NULL}"
+ real match_score
text status
+ text feedback_a
+ text feedback_b
+ timestamptz created_at "{NOT_NULL}"
}
members {
- boolean active
- text bio
- timestamp_with_time_zone created_at "{NOT_NULL}"
- text email UK "{NOT_NULL}"
- text extra_notes
- text full_name "{NOT_NULL}"
uuid id PK "{NOT_NULL}"
- text industry
- text linked_url
- text match_pref
+ text first_name "{NOT_NULL}"
+ text last_name "{NOT_NULL}"
+ text email UK "{NOT_NULL}"
+ text linked_in_url
+ text introduction
text referral_source
- text role
+ boolean active
+ text match_pref
+ text industry_pref
+ text role_pref
text topics
- timestamp_with_time_zone updated_at "{NOT_NULL}"
+ text extra_notes
+ timestamptz created_at "{NOT_NULL}"
+ timestamptz updated_at "{NOT_NULL}"
}
matches }o--|| match_cycles : "cycle_id"
diff --git a/js/src/features/emails/_components/CsvUploader.tsx b/js/src/features/emails/_components/CsvUploader.tsx
index ce7571a..7f1d6ca 100644
--- a/js/src/features/emails/_components/CsvUploader.tsx
+++ b/js/src/features/emails/_components/CsvUploader.tsx
@@ -108,7 +108,8 @@ export function UserCsvUpload({
setRows(
Array.from(users.values()).map((user) => (
- {user.name}
+ {user.firstName}
+ {user.lastName}
{user.email}
{user.intro}
{user.linkedIn}
@@ -156,7 +157,8 @@ export function UserCsvUpload({
>
- Name
+ First name
+ Last name
Email
Intro
Linked In
@@ -198,9 +200,11 @@ export function PairingCsvUpload({
setRows(
Array.from(pairs.values()).map((pair) => (
- {pair.fullNameA}
+ {pair.firstNameA}
+ {pair.lastNameA}
{pair.emailA}
- {pair.fullNameB}
+ {pair.firstNameB}
+ {pair.lastNameB}
{pair.emailB}
)),
@@ -237,9 +241,11 @@ export function PairingCsvUpload({
>
- Name #1
+ First name #1
+ Last name #1
Email #1
- Name #2
+ First name #2
+ Last name #2
Email #2
diff --git a/js/src/features/emails/api/emailTemplate.ts b/js/src/features/emails/api/emailTemplate.ts
index 84d4603..6197d60 100644
--- a/js/src/features/emails/api/emailTemplate.ts
+++ b/js/src/features/emails/api/emailTemplate.ts
@@ -9,7 +9,7 @@
* Message-level variables are referenced un-prefixed (e.g. ${pair}); per-recipient
* variables are referenced positionally as ${per.x} / ${per.x}.
*
- * Note: Users are refered to as per1.name even if the email is only sent to one
+ * Note: Users are referred to as per1.firstName even if the email is only sent to one
* user. Also, attributes such as 'intro' is reference without the perN prefix in
* emails sent to individual users, but would be referenced with the perN prefix
* if included in a message sent to multiple users.
@@ -52,7 +52,7 @@ export function emailTemplate(
export const SAMPLE_TEMPLATE: EmailTemplate = emailTemplate(
"Sample",
"You're paired!",
- `Hi \${per1.name} and \${per2.name}.\n\nYou're paired with \${per1.name} & \${per2.name}.`,
+ `Hi \${per1.firstName} \${per1.lastName} and \${per2.firstName} \${per2.lastName}.\n\nYou're paired with \${per1.firstName} & \${per2.firstName}.`,
"coordinator@patinanetwork.org",
);
@@ -65,10 +65,10 @@ export const PAIR_TEMPLATE: EmailTemplate = emailTemplate(
`Hey \${per1.firstName} and \${per2.firstName}! \n
We've paired you two for PatChats this month! Find some time to have a 30 minute coffee chat or video call with your pairing!
Share a screenshot or selfie of you two in the #pat-chats channel on the Discord server! \n
-\${per1.name} (\${per1.email}):
+\${per1.firstName} \${per1.lastName} (\${per1.email}):
\${per1.intro: ${INTRO_MISSING}}
\${per1.linkedin:}\n
-\${per2.name} (\${per2.email}):
+\${per2.firstName} \${per2.lastName} (\${per2.email}):
\${per2.intro: ${INTRO_MISSING}}
\${per2.linkedin:}\n
Let me know if you'd like to update your pairing information or want to be taken off the list.\n
@@ -85,8 +85,8 @@ Just a friendly reminder that you were paired for PatChats this month!
If you haven't had your 30 minute coffee chat or video call yet, now's a great time to schedule it.
Don't forget to share a screenshot or selfie in the #pat-chats channel on the Discord server!
-\${per1.name} (\${per1.email})
-\${per2.name} (\${per2.email})
+\${per1.firstName} \${per1.lastName} (\${per1.email})
+\${per2.firstName} \${per2.lastName} (\${per2.email})
Let me know if you'd like to update your pairing information or be taken off the list.
diff --git a/js/src/features/emails/api/parseCSV.ts b/js/src/features/emails/api/parseCSV.ts
index 05c4c0c..9c10ee0 100644
--- a/js/src/features/emails/api/parseCSV.ts
+++ b/js/src/features/emails/api/parseCSV.ts
@@ -22,11 +22,12 @@ export const dataToSendRequest = async (
Object.entries(vars).filter(([, value]) => value?.trim()),
);
- // Turn a full User record into a recipient oject with their variables.
+ // Turn a User record into a recipient object with their variables.
const toRecipient = (u: User) => ({
email: u.email,
variableToValue: withoutEmpty({
- name: u.name,
+ firstName: u.firstName,
+ lastName: u.lastName,
email: u.email,
intro: u.intro,
linkedIn: u.linkedIn,
@@ -34,14 +35,12 @@ export const dataToSendRequest = async (
preferences: u.preferences,
topics: u.topics,
anything: u.anything,
- firstName: (u.name ?? "").split(" ")[0],
- lastName: (u.name ?? "").split(" ").slice(1).join(" "),
}),
});
let messages;
if (pairList.length > 0) {
- // One message per pair, addressed to two recipients. The pairing file only gives us names + emails,
+ // One message per pair, addressed to two recipients. The pairing file only gives us names and emails,
// so we look each person up in userMap to add the user with their full set of variables.
messages = pairList.map((p) => {
const userA = userMap.get(p.emailA);
@@ -98,7 +97,8 @@ export async function parseUserFile(
// Check User CSV for required headers
const headers = results.meta.fields ?? [];
const requiredHeaders = [
- "name",
+ "firstName",
+ "lastName",
"email",
"intro",
"linkedIn",
@@ -119,10 +119,13 @@ export async function parseUserFile(
for (const userData of results.data) {
const emailValue = userData.email?.trim();
if (!emailValue) {
- throw new Error(`Missing email for user: ${userData.name}`);
+ throw new Error(
+ `Missing email for user: ${userData.firstName} ${userData.lastName}`,
+ );
}
const user: User = {
- name: userData.name,
+ firstName: userData.firstName,
+ lastName: userData.lastName,
// Trim so trailing whitespace from the CSV doesn't fail backend email validation.
email: emailValue,
intro: userData.intro,
@@ -160,11 +163,13 @@ export async function parsePairingFile(pairFile: File): Promise {
const pairings = [];
for (const pairData of results.data) {
const pair: Pair = {
- fullNameA: pairData[0],
+ firstNameA: pairData[0],
+ lastNameA: pairData[1],
// Trim so emails match the trimmed userMap keys and pass backend validation.
- emailA: pairData[1]?.trim(),
- fullNameB: pairData[2],
- emailB: pairData[3]?.trim(),
+ emailA: pairData[2]?.trim(),
+ firstNameB: pairData[3],
+ lastNameB: pairData[4],
+ emailB: pairData[5]?.trim(),
};
pairings.push(pair);
}
diff --git a/js/src/features/emails/dto/emailDto.ts b/js/src/features/emails/dto/emailDto.ts
index 858d828..703b700 100644
--- a/js/src/features/emails/dto/emailDto.ts
+++ b/js/src/features/emails/dto/emailDto.ts
@@ -1,5 +1,6 @@
export interface User {
- name: string;
+ firstName: string;
+ lastName: string;
email: string;
intro: string;
linkedIn: string;
@@ -7,14 +8,14 @@ export interface User {
preferences: string;
topics: string;
anything: string;
- firstName?: string;
- lastName?: string;
}
export interface Pair {
- fullNameA: string;
+ firstNameA: string;
+ lastNameA: string;
emailA: string;
- fullNameB: string;
+ firstNameB: string;
+ lastNameB: string;
emailB: string;
}
diff --git a/js/src/features/emails/examples/pairings-test.csv b/js/src/features/emails/examples/pairings-test.csv
index 62caeff..1b34c31 100644
--- a/js/src/features/emails/examples/pairings-test.csv
+++ b/js/src/features/emails/examples/pairings-test.csv
@@ -1,9 +1,9 @@
-Alice Smith,alice@example.com,Bob Jones,bob@example.com
-AB,AB@example.com,CD,CD@example.com
-EF,EF@example.com,GH,GH@example.com
-IJ,IJ@example.com,KL,KL@example.com
-MN,MN@example.com,OP,OP@example.com
-QR,QR@example.com,ST,ST@example.com
-UV,UV@example.com,WX,WX@example.com
-Alice Smith, alice@example.com, YZ,YZ@example.com
-Bob Jones, bob@example.com, IL,IL@example.com
+Alice,Smith,alice@example.com,Bob,Jones,bob@example.com
+AB,,AB@example.com,CD,,CD@example.com
+EF,,EF@example.com,GH,,GH@example.com
+IJ,,IJ@example.com,KL,,KL@example.com
+MN,,MN@example.com,OP,,OP@example.com
+QR,,QR@example.com,ST,,ST@example.com
+UV,,UV@example.com,WX,,WX@example.com
+Alice,Smith, alice@example.com,YZ,,YZ@example.com
+Bob,Jones, bob@example.com,IL,,IL@example.com
diff --git a/js/src/features/emails/examples/users-test.csv b/js/src/features/emails/examples/users-test.csv
index c12f22e..c696e78 100644
--- a/js/src/features/emails/examples/users-test.csv
+++ b/js/src/features/emails/examples/users-test.csv
@@ -1,18 +1,17 @@
-timestamp,name,email,intro,linkedIn,industry,preferences,topics,anything,,,
-4/14/2026 21:30:13,Alice Smith, alice@example.com,"Alice intro",https://www.linkedin.com/in/thmd,SWE,"In order (but no dealbreakers): Industry mentors, industry mentees, mentors, mentee",,,,,
-4/11/2026 11:03:07,Bob Jones, bob@example.com,,,,
-4/11/2026 11:03:07,AB,AB@example.com,,,,
-4/11/2026 11:03:07,CD,CD@example.com,,,,
-4/11/2026 11:03:07,EF,EF@example.com,,,,
-4/11/2026 11:03:07,GH,GH@example.com,,,,
-4/11/2026 11:03:07,IJ,IJ@example.com,,,,
-4/11/2026 11:03:07,KL,KL@example.com,,,,
-4/11/2026 11:03:07,MN,MN@example.com,,,,
-4/11/2026 11:03:07,OP,OP@example.com,,,,
-4/11/2026 11:03:07,QR,QR@example.com,,,,
-4/11/2026 11:03:07,ST,ST@example.com,,,,
-4/11/2026 11:03:07,UV,UV@example.com,,,,
-4/11/2026 11:03:07,WX,WX@example.com,,,,
-4/11/2026 11:03:07,YZ,YZ@example.com,,,,
-4/11/2026 11:03:07,IL,IL@example.com,,,,
-
+timestamp,firstName,lastName,email,intro,linkedIn,industry,preferences,topics,anything,,,
+4/14/2026 21:30:13,Alice,Smith, alice@example.com,"Alice intro",https://www.linkedin.com/in/thmd,SWE,"In order (but no dealbreakers): Industry mentors, industry mentees, mentors, mentee",,,,,
+4/11/2026 11:03:07,Bob,Jones, bob@example.com,,,,
+4/11/2026 11:03:07,AB,,AB@example.com,,,,
+4/11/2026 11:03:07,CD,,CD@example.com,,,,
+4/11/2026 11:03:07,EF,,EF@example.com,,,,
+4/11/2026 11:03:07,GH,,GH@example.com,,,,
+4/11/2026 11:03:07,IJ,,IJ@example.com,,,,
+4/11/2026 11:03:07,KL,,KL@example.com,,,,
+4/11/2026 11:03:07,MN,,MN@example.com,,,,
+4/11/2026 11:03:07,OP,,OP@example.com,,,,
+4/11/2026 11:03:07,QR,,QR@example.com,,,,
+4/11/2026 11:03:07,ST,,ST@example.com,,,,
+4/11/2026 11:03:07,UV,,UV@example.com,,,,
+4/11/2026 11:03:07,WX,,WX@example.com,,,,
+4/11/2026 11:03:07,YZ,,YZ@example.com,,,,
+4/11/2026 11:03:07,IL,,IL@example.com,,,,
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java b/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java
index fdc4325..169b9ad 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java
@@ -1,5 +1,6 @@
package org.patinanetwork.patchats.api.member;
+import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.patinanetwork.patchats.api.member.db.models.Member;
@@ -40,6 +41,10 @@ public MemberDto createMember(CreateMemberRequest request) {
return MemberDto.from(createdMember);
}
+ public List getMembers() {
+ return memberRepo.getMembers().stream().map(MemberDto::from).toList();
+ }
+
public MemberDto updateMember(UpdateMemberRequest request, UUID id) {
Member member = memberRepo.getMemberById(id).orElseThrow(() -> new MemberNotFoundException(id));
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java
index 304d0d8..8d280e6 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java
@@ -1,5 +1,6 @@
package org.patinanetwork.patchats.api.member.db.repos;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.patinanetwork.patchats.api.member.db.models.Member;
@@ -21,6 +22,8 @@ public interface MemberRepo {
*/
Member createMember(Member member);
+ List getMembers();
+
/**
* @note - The provided object's methods will be overridden with any returned data from the database.
* @param member - overridden fields:
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java
index e4c312a..ccc65d4 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java
@@ -3,6 +3,7 @@
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@@ -90,6 +91,12 @@ public Member createMember(Member member) {
.single();
}
+ @Override
+ public List getMembers() {
+ String sql = "SELECT * FROM members ORDER BY created_at DESC, id";
+ return jdbc.sql(sql).query((rs, rowNum) -> parseResultSetToMember(rs)).list();
+ }
+
@Override
public Optional updateMember(Member member) {
throw new UnsupportedOperationException("Not implemented yet");
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
index 722421f..8532e87 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
@@ -32,7 +32,7 @@ class MemberControllerTest {
private MemberService memberService;
@Test
- void createMember_ReturnsOkAndMemberDto() throws Exception {
+ void createMemberReturnsOkAndMemberDto() throws Exception {
final CreateMemberRequest request = new CreateMemberRequest(
"John",
"Doe",
@@ -85,7 +85,7 @@ void createMember_ReturnsOkAndMemberDto() throws Exception {
}
@Test
- void createMember_returnsBadRequestWhenFirstNameIsBlank() throws Exception {
+ void createMemberReturnsBadRequestWhenFirstNameIsBlank() throws Exception {
mockMvc.perform(
post("/api/members")
.contentType(MediaType.APPLICATION_JSON)
@@ -96,7 +96,7 @@ void createMember_returnsBadRequestWhenFirstNameIsBlank() throws Exception {
}
@Test
- void createMember_returnsConflictOnDuplicateEmail() throws Exception {
+ void createMemberReturnsConflictOnDuplicateEmail() throws Exception {
when(memberService.createMember(any())).thenThrow(new MemberDuplicateException("john.doe@example.com"));
mockMvc.perform(
post("/api/members")
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
index 251a49c..8bf3318 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
@@ -27,7 +27,7 @@ class MemberServiceTest {
private final MemberService memberService = new MemberService(memberRepo);
@Test
- void createMember_successWithAllFieldsFilled() {
+ void createMemberSuccessWithAllFieldsFilled() {
final CreateMemberRequest request = new CreateMemberRequest(
"John",
"Doe",
@@ -77,7 +77,7 @@ void createMember_successWithAllFieldsFilled() {
}
@Test
- void createMember_throwsExceptionWhenEmailAlreadyExists() {
+ void createMemberThrowsExceptionWhenEmailAlreadyExists() {
final CreateMemberRequest request = new CreateMemberRequest(
"John",
"Doe",
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java
new file mode 100644
index 0000000..f40a80e
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java
@@ -0,0 +1,43 @@
+package org.patinanetwork.patchats.api.member.db.repos;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.patinanetwork.patchats.api.member.db.models.Member;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.simple.JdbcClient;
+
+class MemberSqlRepoTest {
+
+ private static final String GET_MEMBERS_SQL = "SELECT * FROM members ORDER BY created_at DESC, id";
+
+ @Test
+ void getMembersReturnsRowsFromDatabase() {
+ final JdbcClient jdbc = mock(JdbcClient.class);
+ final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class);
+ final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class);
+ final Member member = Member.builder()
+ .id(UUID.randomUUID())
+ .firstName("Alex")
+ .lastName("Morgan")
+ .email("alex@example.com")
+ .active(true)
+ .build();
+
+ when(jdbc.sql(GET_MEMBERS_SQL)).thenReturn(statement);
+ when(statement.query(ArgumentMatchers.>any())).thenReturn(query);
+ when(query.list()).thenReturn(List.of(member));
+
+ final List result = new MemberSqlRepo(jdbc).getMembers();
+
+ assertEquals(List.of(member), result);
+ verify(jdbc).sql(GET_MEMBERS_SQL);
+ verify(query).list();
+ }
+}