diff --git a/client/package-lock.json b/client/package-lock.json index 19a6901..d059f0d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "yclient", "version": "0.0.1", "dependencies": { + "jwt-decode": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0", @@ -4725,6 +4726,14 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/client/package.json b/client/package.json index 6659bca..e01db4c 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ }, "browserslist": "> 0.5%, last 2 versions, not dead", "dependencies": { + "jwt-decode": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index dd4e6fe..09a8ced 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,7 +10,12 @@ import { deleteToken, getToken } from "./api/utils"; import { Login } from "./Login"; import { Signup } from "./Signup"; import { Home } from "./Home"; -import { loginAction, profileSelfAction, signupAction } from "./actions"; +import { + loginAction, + profileSelfAction, + signupAction, + userListAction, +} from "./actions"; import { homeLoader, profileLoader, userListLoader } from "./loaders"; import { isError } from "./api/dto"; import { Feed } from "./Feed"; @@ -39,7 +44,12 @@ const router = createBrowserRouter([ children: [ { path: "feed", element: }, { path: "messages", element: }, - { path: "users", element: , loader: userListLoader }, + { + path: "users", + element: , + loader: userListLoader, + action: userListAction, + }, { path: "profile", loader: profileLoader, diff --git a/client/src/Home.tsx b/client/src/Home.tsx index a579106..b0a083e 100644 --- a/client/src/Home.tsx +++ b/client/src/Home.tsx @@ -3,6 +3,7 @@ import { homeLoader, LoaderToType } from "./loaders"; import { isError } from "./api/dto"; import "./Home.scss"; +import { HomeContextType } from "./HomeContext"; export function Home() { const loaderData = useLoaderData() as LoaderToType; @@ -42,7 +43,9 @@ export function Home() {
- +
); diff --git a/client/src/HomeContext.tsx b/client/src/HomeContext.tsx new file mode 100644 index 0000000..b6d1760 --- /dev/null +++ b/client/src/HomeContext.tsx @@ -0,0 +1,10 @@ +import { TPersonTo } from "./api/dto"; +import { useOutletContext } from "react-router-dom"; + +export type HomeContextType = { + user: TPersonTo; +}; + +export function useHomeContext() { + return useOutletContext(); +} diff --git a/client/src/Profile.tsx b/client/src/Profile.tsx index d64f0d0..4149eb1 100644 --- a/client/src/Profile.tsx +++ b/client/src/Profile.tsx @@ -3,6 +3,7 @@ import { Form, Link, useLoaderData } from "react-router-dom"; import { LoaderToType, profileLoader } from "./loaders"; import { isError } from "./api/dto"; import { Post } from "./Post"; +import { useHomeContext } from "./HomeContext"; export interface IProfileProps { self: boolean; @@ -11,6 +12,8 @@ export interface IProfileProps { export function Profile({ self }: IProfileProps) { const loaderData = useLoaderData() as LoaderToType; + const homeContext = useHomeContext(); + if (!loaderData || isError(loaderData)) { return
Error
; } @@ -22,8 +25,8 @@ export function Profile({ self }: IProfileProps) { return (
- {loaderData.user.fullName} - {loaderData.user.fullName} + {homeContext.user.fullName} + {homeContext.user.fullName}
{self && (
diff --git a/client/src/ProfileCard.tsx b/client/src/ProfileCard.tsx index 614813a..b8a4b66 100644 --- a/client/src/ProfileCard.tsx +++ b/client/src/ProfileCard.tsx @@ -1,12 +1,18 @@ import "./ProfileCard.scss"; -import { Link } from "react-router-dom"; +import { Form, Link } from "react-router-dom"; export function ProfileCard({ username, fullName, + uuid, + actions, + alreadyFollowing, }: { username: string; fullName: string; + uuid: string; + actions: boolean; + alreadyFollowing: boolean; }) { return (
@@ -16,6 +22,30 @@ export function ProfileCard({ {username} + {actions && + (alreadyFollowing ? ( +
+ + +
+ ) : ( +
+ + +
+ ))}
); } diff --git a/client/src/UserList.tsx b/client/src/UserList.tsx index 6cf445d..eb02ed9 100644 --- a/client/src/UserList.tsx +++ b/client/src/UserList.tsx @@ -2,22 +2,33 @@ import { useLoaderData } from "react-router-dom"; import { LoaderToType, userListLoader } from "./loaders"; import { isError } from "./api/dto"; import { ProfileCard } from "./ProfileCard"; +import { useHomeContext } from "./HomeContext"; export function UserList() { const loaderData = useLoaderData() as LoaderToType; + const homeContext = useHomeContext(); - if (!loaderData || isError(loaderData)) { + if (!loaderData) { return
Error
; } + const { people, following } = loaderData; + if (isError(following) || isError(people)) { + return
Error
; + } return (
- {loaderData.map((u) => { + {people.map((u) => { return ( f.uuid == u.uuid, + )} /> ); })} diff --git a/client/src/actions.ts b/client/src/actions.ts index 75ff543..71dcd68 100644 --- a/client/src/actions.ts +++ b/client/src/actions.ts @@ -1,4 +1,4 @@ -import { signup } from "./api/Person"; +import { addFollower, removeFollower, signup } from "./api/Person"; import { ActionFunctionArgs, redirect } from "react-router-dom"; import { login } from "./api/Token"; import { isError } from "./api/dto"; @@ -61,3 +61,14 @@ export async function profileSelfAction({ request }: ActionFunctionArgs) { ); } } + +export async function userListAction({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent")!.toString(); + console.log(intent); + if (intent == "follow") { + return await addFollower(formData.get("uuid")!.toString()); + } else if (intent == "unfollow") { + return await removeFollower(formData.get("uuid")!.toString()); + } +} diff --git a/client/src/api/Person.ts b/client/src/api/Person.ts index 9b19ba9..6140f08 100644 --- a/client/src/api/Person.ts +++ b/client/src/api/Person.ts @@ -1,7 +1,9 @@ import { fetchJSON, fetchJSONAuth } from "./utils"; import { + NoContentToResp, PersonToArrResp, PersonToResp, + TNoContentToResp, TPersonToArrResp, TPersonToResp, } from "./dto"; @@ -30,6 +32,10 @@ export async function getAllPerson(): Promise { return fetchJSONAuth("/person", "GET", PersonToArrResp); } +export async function getFollowing(): Promise { + return fetchJSONAuth("/person/following", "GET", PersonToArrResp); +} + export async function getPersonByUsername( username: string, ): Promise { @@ -39,3 +45,15 @@ export async function getPersonByUsername( PersonToResp, ); } + +export async function addFollower(uuid: string): Promise { + return fetchJSONAuth("/person/following/" + uuid, "PUT", NoContentToResp); +} + +export async function removeFollower(uuid: string): Promise { + return fetchJSONAuth( + "/person/following/" + uuid, + "DELETE", + NoContentToResp, + ); +} diff --git a/client/src/api/Post.ts b/client/src/api/Post.ts index 65d6218..e285e8a 100644 --- a/client/src/api/Post.ts +++ b/client/src/api/Post.ts @@ -17,6 +17,22 @@ export async function deletePost(id: number): Promise { return fetchJSONAuth(`/post/${id.toString()}`, "DELETE", NoContentToResp); } -export async function getPosts(author: string): Promise { - return fetchJSONAuth(`/post?author=${author}`, "GET", PostToArrResp); +export async function getPostsByAuthorUuid( + author: string, +): Promise { + return fetchJSONAuth( + `/post/by-author-uuid?author=${author}`, + "GET", + PostToArrResp, + ); +} + +export async function getPostsByAuthorUsername( + author: string, +): Promise { + return fetchJSONAuth( + `/post/by-author-username?author=${author}`, + "GET", + PostToArrResp, + ); } diff --git a/client/src/api/utils.ts b/client/src/api/utils.ts index bf309ef..8591627 100644 --- a/client/src/api/utils.ts +++ b/client/src/api/utils.ts @@ -1,5 +1,8 @@ // import { apiRoot } from "~src/env"; +import { jwtDecode } from "jwt-decode"; +import { isError, TErrorTo } from "./dto"; + const apiRoot: string = "http://localhost:8080"; let token: string | null; @@ -16,6 +19,12 @@ export function getToken(): string | null { return token; } +export function getTokenUserUuid(): string | null { + const token = getToken(); + if (!token) return null; + return jwtDecode(token).sub ?? null; +} + export function deleteToken(): void { token = null; localStorage.removeItem("jwt_token"); diff --git a/client/src/loaders.ts b/client/src/loaders.ts index 2f5f14a..0ff881b 100644 --- a/client/src/loaders.ts +++ b/client/src/loaders.ts @@ -1,8 +1,13 @@ -import { getAllPerson, getPersonByUsername, getSelf } from "./api/Person"; -import { deleteToken, getToken } from "./api/utils"; +import { + getAllPerson, + getFollowing, + getPersonByUsername, + getSelf, +} from "./api/Person"; +import { deleteToken, getToken, getTokenUserUuid } from "./api/utils"; import { redirect } from "react-router-dom"; import { isError } from "./api/dto"; -import { getPosts } from "./api/Post"; +import { getPostsByAuthorUsername, getPostsByAuthorUuid } from "./api/Post"; export type LoaderToType any> = | Exclude>, Response> @@ -25,7 +30,7 @@ export async function homeLoader() { } export async function userListLoader() { - return await getAllPerson(); + return { people: await getAllPerson(), following: await getFollowing() }; } export async function profileLoader({ @@ -33,27 +38,31 @@ export async function profileLoader({ }: { params: { username?: string }; }) { - const self = await getCheckUserSelf(); - if (!self || self instanceof Response || isError(self)) { - return self; - } - - if (self.username == params.username) { + const selfUuid = getTokenUserUuid(); + if (!selfUuid) return redirect("/"); + if (selfUuid == params.username) { return redirect("/home/profile"); } - const user = params.username - ? await getPersonByUsername(params.username) - : self; - if (!user || user instanceof Response || isError(user)) { - return user; - } + const posts = params.username + ? await getPostsByAuthorUsername(params.username) + : await getPostsByAuthorUuid(selfUuid); - const posts = await getPosts(user.uuid); + const retUser = params.username + ? await getPersonByUsername(params.username) + : null; + + if ( + (params.username && !retUser) || + retUser instanceof Response || + isError(retUser) + ) { + return retUser; + } if (isError(posts)) { - return { user, posts: null }; + return { user: retUser, posts: null }; } - return { user, posts }; + return { user: retUser, posts }; } diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/controller/PersonController.java b/server/src/main/java/com/usatiuk/tjv/y/server/controller/PersonController.java index c4410b8..7d016f4 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/controller/PersonController.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/controller/PersonController.java @@ -7,6 +7,7 @@ import com.usatiuk.tjv.y.server.entity.Person; import com.usatiuk.tjv.y.server.service.PersonService; import com.usatiuk.tjv.y.server.service.exceptions.UserAlreadyExistsException; import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; @@ -79,4 +80,16 @@ public class PersonController { return personService.getFollowing(principal.getName()).stream().map(PersonMapper::makeDto); } + @PutMapping(path = "/following/{uuid}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void addFollowing(Principal principal, @PathVariable String uuid) throws UserNotFoundException { + personService.addFollower(principal.getName(), uuid); + } + + @DeleteMapping(path = "/following/{uuid}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteFollowing(Principal principal, @PathVariable String uuid) throws UserNotFoundException { + personService.removeFollower(principal.getName(), uuid); + } + } diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/controller/PostController.java b/server/src/main/java/com/usatiuk/tjv/y/server/controller/PostController.java index a878fb8..12e9920 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/controller/PostController.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/controller/PostController.java @@ -36,8 +36,16 @@ public class PostController { return PostMapper.makeDto(postService.create(post)); } - @GetMapping - public Stream readAllByAuthor(@RequestParam Optional author) { + @GetMapping(path = "/by-author-uuid") + public Stream readAllByAuthorUuid(@RequestParam Optional author) { + if (author.isPresent()) + return postService.readByAuthorId(author.get()).stream().map(PostMapper::makeDto); + else + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + @GetMapping(path = "/by-author-username") + public Stream readAllByAuthorUsername(@RequestParam Optional author) { if (author.isPresent()) return postService.readByAuthorId(author.get()).stream().map(PostMapper::makeDto); else diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/repository/PostRepository.java b/server/src/main/java/com/usatiuk/tjv/y/server/repository/PostRepository.java index a6fa220..3bdb319 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/repository/PostRepository.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/repository/PostRepository.java @@ -12,6 +12,8 @@ import java.util.Collection; public interface PostRepository extends PagingAndSortingRepository, CrudRepository { Collection findByAuthorUuid(String authorUuid); + Collection findByAuthorUsername(String authorUsername); + @Query(value = "SELECT p FROM Post p " + "WHERE EXISTS " + "(SELECT u FROM Person u LEFT JOIN u.following f where u.uuid = :personUuid and f.uuid = p.author.uuid)") diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonService.java b/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonService.java index 78a0abe..4892df8 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonService.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonService.java @@ -17,4 +17,8 @@ public interface PersonService extends CrudService { Collection getFollowers(String uuid) throws UserNotFoundException; Collection getFollowing(String uuid) throws UserNotFoundException; + + void addFollower(String follower, String followee) throws UserNotFoundException; + + void removeFollower(String follower, String followee) throws UserNotFoundException; } diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonServiceImpl.java b/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonServiceImpl.java index 297b693..881d1e7 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonServiceImpl.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/service/PersonServiceImpl.java @@ -4,6 +4,7 @@ import com.usatiuk.tjv.y.server.entity.Person; import com.usatiuk.tjv.y.server.repository.PersonRepository; import com.usatiuk.tjv.y.server.service.exceptions.UserAlreadyExistsException; import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException; +import jakarta.persistence.EntityManager; import org.springframework.data.repository.CrudRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -15,11 +16,13 @@ import java.util.Optional; public class PersonServiceImpl extends CrudServiceImpl implements PersonService { private final PersonRepository personRepository; private final PasswordEncoder passwordEncoder; + private final EntityManager entityManager; public PersonServiceImpl(PersonRepository personRepository, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, EntityManager entityManager) { this.personRepository = personRepository; this.passwordEncoder = passwordEncoder; + this.entityManager = entityManager; } @Override @@ -59,4 +62,18 @@ public class PersonServiceImpl extends CrudServiceImpl implement public Collection getFollowing(String uuid) throws UserNotFoundException { return personRepository.findById(uuid).orElseThrow(UserNotFoundException::new).getFollowing(); } + + @Override + public void addFollower(String follower, String followee) throws UserNotFoundException { + var person = personRepository.findById(follower).orElseThrow(UserNotFoundException::new); + person.getFollowing().add(entityManager.getReference(Person.class, followee)); + personRepository.save(person); + } + + @Override + public void removeFollower(String follower, String followee) throws UserNotFoundException { + var person = personRepository.findById(follower).orElseThrow(UserNotFoundException::new); + person.getFollowing().remove(entityManager.getReference(Person.class, followee)); + personRepository.save(person); + } } diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/service/PostService.java b/server/src/main/java/com/usatiuk/tjv/y/server/service/PostService.java index 93d76db..0074b90 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/service/PostService.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/service/PostService.java @@ -7,5 +7,7 @@ import java.util.Collection; public interface PostService extends CrudService { Collection readByAuthorId(String authorUuid); + Collection readByAuthorUsername(String authorUsername); + Collection readByPersonFollowees(String personUuid); } diff --git a/server/src/main/java/com/usatiuk/tjv/y/server/service/PostServiceImpl.java b/server/src/main/java/com/usatiuk/tjv/y/server/service/PostServiceImpl.java index cc89371..7062eab 100644 --- a/server/src/main/java/com/usatiuk/tjv/y/server/service/PostServiceImpl.java +++ b/server/src/main/java/com/usatiuk/tjv/y/server/service/PostServiceImpl.java @@ -25,6 +25,11 @@ public class PostServiceImpl extends CrudServiceImpl implements Post return postRepository.findByAuthorUuid(authorId); } + @Override + public Collection readByAuthorUsername(String authorUsername) { + return postRepository.findByAuthorUsername(authorUsername); + } + @Override public Collection readByPersonFollowees(String personUuid) { return postRepository.findByPersonFollowees(personUuid); diff --git a/server/src/test/java/com/usatiuk/tjv/y/server/controller/PersonControllerTest.java b/server/src/test/java/com/usatiuk/tjv/y/server/controller/PersonControllerTest.java index 2dd6595..87afe55 100644 --- a/server/src/test/java/com/usatiuk/tjv/y/server/controller/PersonControllerTest.java +++ b/server/src/test/java/com/usatiuk/tjv/y/server/controller/PersonControllerTest.java @@ -119,4 +119,25 @@ public class PersonControllerTest extends DemoDataDbTest { Assertions.assertIterableEquals(Arrays.asList(personToResponse), List.of(PersonMapper.makeDto(person2), PersonMapper.makeDto(person1))); } + @Test + void shouldAddFollowing() { + var response = restTemplate.exchange(addr + "/person/following/" + person3.getUuid(), + HttpMethod.PUT, new HttpEntity<>(createAuthHeaders(person1Auth)), Object.class); + + Assertions.assertNotNull(response); + Assertions.assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + + var response2 = restTemplate.exchange(addr + "/person/following", + HttpMethod.GET, new HttpEntity<>(createAuthHeaders(person1Auth)), PersonTo[].class); + + Assertions.assertNotNull(response2); + Assertions.assertEquals(HttpStatus.OK, response2.getStatusCode()); + + var personToResponse = response2.getBody(); + Assertions.assertNotNull(personToResponse); + + Assertions.assertEquals(1, personToResponse.length); + Assertions.assertIterableEquals(Arrays.asList(personToResponse), List.of(PersonMapper.makeDto(person3))); + } + } diff --git a/server/src/test/java/com/usatiuk/tjv/y/server/controller/PostControllerTest.java b/server/src/test/java/com/usatiuk/tjv/y/server/controller/PostControllerTest.java index 488824d..80a9edc 100644 --- a/server/src/test/java/com/usatiuk/tjv/y/server/controller/PostControllerTest.java +++ b/server/src/test/java/com/usatiuk/tjv/y/server/controller/PostControllerTest.java @@ -60,7 +60,7 @@ public class PostControllerTest extends DemoDataDbTest { @Test void shouldGetByAuthor() { - var response = restTemplate.exchange(addr + "/post?author=" + person1.getUuid(), HttpMethod.GET, + var response = restTemplate.exchange(addr + "/post/by-author-uuid?author=" + person1.getUuid(), HttpMethod.GET, HttpEntity.EMPTY, PostTo[].class); Assertions.assertEquals(HttpStatus.OK, response.getStatusCode());