diff --git a/db/migration/V0004__Alter_members_table.sql b/db/migration/V0004__Alter_members_table.sql new file mode 100644 index 0000000..a5d7cf9 --- /dev/null +++ b/db/migration/V0004__Alter_members_table.sql @@ -0,0 +1,7 @@ +ALTER TABLE members RENAME COLUMN linked_url TO linked_in_url; +ALTER TABLE members RENAME COLUMN bio TO introduction; +ALTER TABLE members RENAME COLUMN industry TO industry_pref; +ALTER TABLE members RENAME COLUMN role TO role_pref; + +ALTER TABLE members ALTER COLUMN introduction SET NOT NULL; +ALTER TABLE members ALTER COLUMN active SET NOT NULL DEFAULT TRUE; \ No newline at end of file diff --git a/js/src/features/sign-up/components/SignUpForm.tsx b/js/src/features/sign-up/components/SignUpForm.tsx index 958ce5c..2a90e52 100644 --- a/js/src/features/sign-up/components/SignUpForm.tsx +++ b/js/src/features/sign-up/components/SignUpForm.tsx @@ -1,9 +1,9 @@ -import { signUpFormSchema } from "@/features/sign-up/api/schemas"; import { INDUSTRIES, MATCHING_PREFERENCES, } from "@/features/sign-up/components/signUpFormConfig"; -import { SignUpFormValues } from "@/features/sign-up/types"; +import { userProfileSchema } from "@/features/user-profile/api/schemas"; +import { UserProfileValues } from "@/features/user-profile/types"; import { Alert, Button, @@ -27,7 +27,7 @@ const matchingPreferenceOptions = MATCHING_PREFERENCES.map((v) => ({ const industryOptions = INDUSTRIES.map((v) => ({ value: v, label: v })); // Define initial values -const initialFormValues: SignUpFormValues = { +const initialFormValues: UserProfileValues = { fullName: "", email: "", linkedin: "", @@ -42,12 +42,12 @@ const initialFormValues: SignUpFormValues = { export function SignUpForm() { // State management - const [values, setValues] = useState(() => ({ + const [values, setValues] = useState(() => ({ ...initialFormValues, })); const [errors, setErrors] = useState< - Partial> + Partial> >({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -65,9 +65,9 @@ export function SignUpForm() { }; // Handlers - const handleFieldChange = ( + const handleFieldChange = ( field: K, - value: SignUpFormValues[K], + value: UserProfileValues[K], ) => { setValues((current) => ({ ...current, [field]: value })); setErrors((current) => ({ ...current, [field]: undefined })); @@ -76,11 +76,11 @@ export function SignUpForm() { }; const handleFieldBlur = ( - field: keyof SignUpFormValues, + field: keyof UserProfileValues, overrideValue?: string, ) => { const valueToValidate = overrideValue ?? values[field]; - const fieldSchema = signUpFormSchema.pick({ [field]: true } as Record< + const fieldSchema = userProfileSchema.pick({ [field]: true } as Record< typeof field, true >); @@ -92,15 +92,15 @@ export function SignUpForm() { }; // Validation logic - const validateForm = (values: SignUpFormValues) => { - const result = signUpFormSchema.safeParse(values); + const validateForm = (values: UserProfileValues) => { + const result = userProfileSchema.safeParse(values); const fieldErrors = result.success ? {} : result.error?.flatten().fieldErrors; - const errors = {} as Partial>; + const errors = {} as Partial>; for (const key in fieldErrors) { - if (fieldErrors[key as keyof SignUpFormValues]?.[0]) { - errors[key as keyof SignUpFormValues] = - fieldErrors[key as keyof SignUpFormValues]?.[0]; + if (fieldErrors[key as keyof UserProfileValues]?.[0]) { + errors[key as keyof UserProfileValues] = + fieldErrors[key as keyof UserProfileValues]?.[0]; } } return errors; @@ -211,7 +211,7 @@ export function SignUpForm() { onChange={(value) => handleFieldChange( "matchingPreference", - (value || "") as SignUpFormValues["matchingPreference"], + (value || "") as UserProfileValues["matchingPreference"], ) } onBlur={() => handleFieldBlur("matchingPreference")} @@ -225,7 +225,7 @@ export function SignUpForm() { onChange={(value) => handleFieldChange( "industry", - (value || "") as SignUpFormValues["industry"], + (value || "") as UserProfileValues["industry"], ) } onBlur={() => handleFieldBlur("industry")} diff --git a/js/src/features/sign-up/components/signUpFormConfig.ts b/js/src/features/sign-up/components/signUpFormConfig.ts index 5a97228..9ae4139 100644 --- a/js/src/features/sign-up/components/signUpFormConfig.ts +++ b/js/src/features/sign-up/components/signUpFormConfig.ts @@ -5,14 +5,6 @@ export const MATCHING_PREFERENCES = [ "No Preference - I am open to any type of match", ] as const; -export const TALKING_POINTS = [ - "Career journey", - "Advice", - "Current events", - "Hobbies", - "Other", -] as const; - export const INDUSTRIES = [ "Technology", "Finance", diff --git a/js/src/features/user-profile/UserProfile.page.tsx b/js/src/features/user-profile/UserProfile.page.tsx new file mode 100644 index 0000000..dc5a411 --- /dev/null +++ b/js/src/features/user-profile/UserProfile.page.tsx @@ -0,0 +1,9 @@ +import { Stack, Title } from "@mantine/core"; + +export function UserProfilePage() { + return ( + + User Profile + + ); +} diff --git a/js/src/features/sign-up/api/schemas.ts b/js/src/features/user-profile/api/schemas.ts similarity index 96% rename from js/src/features/sign-up/api/schemas.ts rename to js/src/features/user-profile/api/schemas.ts index b0eb526..b7ee25d 100644 --- a/js/src/features/sign-up/api/schemas.ts +++ b/js/src/features/user-profile/api/schemas.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const signUpFormSchema = z.object({ +export const userProfileSchema = z.object({ fullName: z.string().min(1, "Full Name is required."), email: z .string() diff --git a/js/src/features/sign-up/types.ts b/js/src/features/user-profile/types.ts similarity index 85% rename from js/src/features/sign-up/types.ts rename to js/src/features/user-profile/types.ts index c1a08e4..1268fc3 100644 --- a/js/src/features/sign-up/types.ts +++ b/js/src/features/user-profile/types.ts @@ -1,4 +1,4 @@ -export interface SignUpFormValues { +export interface UserProfileValues { fullName: string; email: string; linkedin: string; diff --git a/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java b/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java new file mode 100644 index 0000000..1d615d6 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java @@ -0,0 +1,48 @@ +package org.patinanetwork.patchats.api.member; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest; +import org.patinanetwork.patchats.api.member.dto.MemberDto; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/members") +@Tag(name = "Member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @PostMapping + public ResponseEntity> createMember(@Valid @RequestBody final CreateMemberRequest request) { + final MemberDto response = memberService.createMember(request); + return ResponseEntity.ok(ApiResponder.success("Member created successfully", response)); + } + + // TODO: Implement these endpoints after createMember is fully functional and tested + // @PatchMapping("/{id}") + // public ResponseEntity> updateMember( + // @Valid @RequestBody final UpdateMemberRequest request, @PathVariable final UUID id) { + // final MemberDto response = memberService.updateMember(request, id); + // return ResponseEntity.ok(ApiResponder.success("Member updated successfully", response)); + // } + + // @GetMapping("/{id}") + // public ResponseEntity> getMember(@PathVariable final UUID id) { + // final MemberDto response = memberService.getMemberById(id); + // return ResponseEntity.ok(ApiResponder.success("Member retrieved successfully", response)); + // } + + // @DeleteMapping("/{id}") + // public ResponseEntity> deactivateMember(@PathVariable final UUID id) { + // final MemberDto response = memberService.deactivateMemberById(id); + // return ResponseEntity.ok(ApiResponder.success("Member deactivated successfully", response)); + // } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java b/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java new file mode 100644 index 0000000..6e46563 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java @@ -0,0 +1,81 @@ +package org.patinanetwork.patchats.api.member; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest; +import org.patinanetwork.patchats.api.member.dto.MemberDto; +import org.patinanetwork.patchats.api.member.dto.UpdateMemberRequest; +import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; +import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepo memberRepo; + + public MemberDto createMember(CreateMemberRequest request) { + if (memberRepo.getMemberByEmail(request.email()).isPresent()) { + throw new MemberDuplicateException(request.email()); + } + Member member = Member.builder() + .id(UUID.randomUUID()) + .fullName(request.fullName()) + .email(request.email()) + .linkedInUrl(request.linkedInUrl()) + .introduction(request.introduction()) + .referralSource(request.referralSource()) + .active(true) + .matchPref(request.matchPref()) + .industryPref(request.industryPref()) + .rolePref(request.rolePref()) + .topics(request.topics()) + .extraNotes(request.extraNotes()) + .build(); + Member createdMember = memberRepo.createMember(member); + return MemberDto.from(createdMember); + } + + public MemberDto updateMember(UpdateMemberRequest request, UUID id) { + if (memberRepo.getMemberById(id).isEmpty()) { + throw new MemberNotFoundException(id); + } + // TODO: Implement partial update instead of full update + Member member = Member.builder() + .id(id) + .fullName(request.fullName()) + .email(request.email()) + .linkedInUrl(request.linkedInUrl()) + .introduction(request.introduction()) + .matchPref(request.matchPref()) + .industryPref(request.industryPref()) + .rolePref(request.rolePref()) + .topics(request.topics()) + .extraNotes(request.extraNotes()) + .build(); + Member updatedMember = memberRepo + .updateMember(member) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + return MemberDto.from(updatedMember); + } + + // TODO: Implement these methods after createMember and updateMember is fully functional and tested + // public MemberDto getMemberById(UUID id) { + // return memberRepo + // .getMemberById(id) + // .map(MemberDto::from) + // .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + // } + + // public MemberDto deactivateMemberById(UUID id) { + // return memberRepo + // .deactivateMemberById(id) + // .map(MemberDto::from) + // .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + // } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/member/db/models/Member.java b/src/main/java/org/patinanetwork/patchats/api/member/db/models/Member.java new file mode 100644 index 0000000..9ecbbd6 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/db/models/Member.java @@ -0,0 +1,55 @@ +package org.patinanetwork.patchats.api.member.db.models; + +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class Member { + + private UUID id; + + @Setter + private String fullName; + + @Setter + private String email; + + @Setter + private String linkedInUrl; + + @Setter + private String introduction; + + @Setter + private String referralSource; + + @Setter + private boolean active; + + @Setter + private String matchPref; + + @Setter + private String industryPref; + + @Setter + private String rolePref; + + @Setter + private String topics; + + @Setter + private String extraNotes; + + private Instant createdAt; + + private Instant updatedAt; +} 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 new file mode 100644 index 0000000..a37a108 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java @@ -0,0 +1,47 @@ +package org.patinanetwork.patchats.api.member.db.repos; + +import java.util.Optional; +import java.util.UUID; +import org.patinanetwork.patchats.api.member.db.models.Member; + +public interface MemberRepo { + /** + * @note - The provided object's methods will be overridden with any returned data from the database. + * @param member - required fields: + *
    + *
  • id + *
  • fullName + *
  • email + *
  • introduction + *
  • active + *
  • createdAt + *
  • updatedAt + *
+ */ + Member createMember(Member member); + + /** + * @note - The provided object's methods will be overridden with any returned data from the database. + * @param member - overridden fields: + *
    + *
  • fullName + *
  • email + *
  • linkedInUrl + *
  • introduction + *
  • referralSource + *
  • active + *
  • matchPref + *
  • industryPref + *
  • rolePref + *
  • topics + *
  • extraNotes + *
+ */ + Optional updateMember(Member member); + + Optional getMemberById(UUID id); + + Optional getMemberByEmail(String email); + + Optional deactivateMemberById(UUID id); +} 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 new file mode 100644 index 0000000..13b5c1e --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java @@ -0,0 +1,83 @@ +package org.patinanetwork.patchats.api.member.db.repos; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberSqlRepo implements MemberRepo { + + private final JdbcClient jdbc; + + private Member parseResultSetToMember(final ResultSet rs) throws SQLException { + return Member.builder() + .id(UUID.fromString(rs.getString("id"))) + .fullName(rs.getString("full_name")) + .email(rs.getString("email")) + .linkedInUrl(rs.getString("linked_in_url")) + .introduction(rs.getString("introduction")) + .referralSource(rs.getString("referral_source")) + .active(rs.getBoolean("active")) + .matchPref(rs.getString("match_pref")) + .industryPref(rs.getString("industry_pref")) + .rolePref(rs.getString("role_pref")) + .topics(rs.getString("topics")) + .extraNotes(rs.getString("extra_notes")) + .createdAt(rs.getTimestamp("created_at").toInstant()) + .updatedAt(rs.getTimestamp("updated_at").toInstant()) + .build(); + } + + @Override + public Member createMember(Member member) { + String sql = """ + INSERT INTO "members" + (id, "full_name", "email","linked_in_url","introduction","referral_source",active, "match_pref", "industry_pref", "role_pref", "topics", "extra_notes") + VALUES + (:id, :full_name, :email, :linked_in_url, :introduction, :referral_source, :active , :match_pref, :industry_pref, :role_pref, :topics, :extra_notes) + RETURNING + * + """; + return jdbc.sql(sql) + .param("id", member.getId()) + .param("full_name", member.getFullName()) + .param("email", member.getEmail()) + .param("linked_in_url", member.getLinkedInUrl()) + .param("introduction", member.getIntroduction()) + .param("referral_source", member.getReferralSource()) + .param("active", member.isActive()) + .param("match_pref", member.getMatchPref()) + .param("industry_pref", member.getIndustryPref()) + .param("role_pref", member.getRolePref()) + .param("topics", member.getTopics()) + .param("extra_notes", member.getExtraNotes()) + .query((rs, rowNum) -> parseResultSetToMember(rs)) + .single(); + } + + @Override + public Optional updateMember(Member member) { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public Optional getMemberById(UUID id) { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public Optional getMemberByEmail(String email) { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public Optional deactivateMemberById(UUID id) { + throw new UnsupportedOperationException("Not implemented yet"); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/member/dto/CreateMemberRequest.java b/src/main/java/org/patinanetwork/patchats/api/member/dto/CreateMemberRequest.java new file mode 100644 index 0000000..f95a4d7 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/dto/CreateMemberRequest.java @@ -0,0 +1,16 @@ +package org.patinanetwork.patchats.api.member.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record CreateMemberRequest( + @NotBlank String fullName, + @NotBlank @Email String email, + String linkedInUrl, + @NotBlank String introduction, + String referralSource, + String matchPref, + String industryPref, + String rolePref, + String topics, + String extraNotes) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java b/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java new file mode 100644 index 0000000..9e9bda3 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/dto/MemberDto.java @@ -0,0 +1,72 @@ +package org.patinanetwork.patchats.api.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.patinanetwork.patchats.api.member.db.models.Member; + +// TODO: Separate AdminMemberDto and PublicMemberDto +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class MemberDto { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String fullName; + + // TODO: Changes to email require verification after authentication is implemented + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String email; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String linkedInUrl; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String introduction; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String matchPref; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String industryPref; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String rolePref; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String topics; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String extraNotes; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Instant createdAt; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Instant updatedAt; + + public static MemberDto from(final Member member) { + return MemberDto.builder() + .id(member.getId()) + .fullName(member.getFullName()) + .email(member.getEmail()) + .linkedInUrl(member.getLinkedInUrl()) + .introduction(member.getIntroduction()) + .matchPref(member.getMatchPref()) + .industryPref(member.getIndustryPref()) + .rolePref(member.getRolePref()) + .topics(member.getTopics()) + .extraNotes(member.getExtraNotes()) + .createdAt(member.getCreatedAt()) + .updatedAt(member.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/member/dto/UpdateMemberRequest.java b/src/main/java/org/patinanetwork/patchats/api/member/dto/UpdateMemberRequest.java new file mode 100644 index 0000000..92b6a29 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/member/dto/UpdateMemberRequest.java @@ -0,0 +1,16 @@ +package org.patinanetwork.patchats.api.member.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record UpdateMemberRequest( + @NotBlank String fullName, + // TODO: Changes to email require verification after authentication is implemented + @NotBlank @Email String email, + String linkedInUrl, + @NotBlank String introduction, + String matchPref, + String industryPref, + String rolePref, + String topics, + String extraNotes) {} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java index 302916b..7501ad8 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -2,6 +2,9 @@ import java.util.stream.Collectors; import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; +import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -21,6 +24,16 @@ public ResponseEntity> handleValidation(final MethodArgumentN return ResponseEntity.badRequest().body(ApiResponder.failure(message)); } + @ExceptionHandler(MemberNotFoundException.class) + public ResponseEntity> handleMemberNotFound(final MemberNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(MemberDuplicateException.class) + public ResponseEntity> handleMemberDuplicate(final MemberDuplicateException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); + } + private String formatError(final FieldError error) { return error.getField() + " " + error.getDefaultMessage(); } diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/MemberDuplicateException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/MemberDuplicateException.java new file mode 100644 index 0000000..798bd98 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/MemberDuplicateException.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.common.web.exception; + +public class MemberDuplicateException extends RuntimeException { + public MemberDuplicateException(String email) { + super("Member with email " + email + " already exists"); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/MemberNotFoundException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/MemberNotFoundException.java new file mode 100644 index 0000000..6bfee93 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/MemberNotFoundException.java @@ -0,0 +1,9 @@ +package org.patinanetwork.patchats.common.web.exception; + +import java.util.UUID; + +public class MemberNotFoundException extends RuntimeException { + public MemberNotFoundException(UUID id) { + super("Member with ID " + id + " not found"); + } +}