diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index 7a4b101b..5ed398a9 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -210,17 +210,11 @@ paths: responses: "200": description: OK - /mentor/session: + /mentor/sessions: get: tags: - session - operationId: getSessions - parameters: - - name: login - in: query - required: true - schema: - type: string + operationId: getAllSessions responses: "200": description: OK @@ -233,13 +227,7 @@ paths: post: tags: - session - operationId: createSession - requestBody: - content: - application/json: - schema: - type: string - required: true + operationId: createNewSession responses: "200": description: OK @@ -247,7 +235,7 @@ paths: application/json: schema: $ref: "#/components/schemas/Session" - /mentor/message/{sessionId}: + /mentor/sessions/{sessionId}: get: tags: - message @@ -534,7 +522,7 @@ components: sender: type: string enum: - - LLM + - MENTOR - USER content: type: string @@ -801,19 +789,11 @@ components: required: - createdAt - id - - messages - - userLogin type: object properties: id: type: integer format: int64 - messages: - type: array - items: - $ref: "#/components/schemas/Message" - userLogin: - type: string createdAt: type: string format: date-time diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityUtils.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityUtils.java new file mode 100644 index 00000000..95a4447f --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityUtils.java @@ -0,0 +1,31 @@ +package de.tum.in.www1.hephaestus; + +import java.util.Optional; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * Utility class for Spring Security. + */ +public final class SecurityUtils { + + private SecurityUtils() {} + + /** + * Get the login of the current user. + * + * @return the login of the current user. + */ + public static Optional getCurrentUserLogin() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return Optional.empty(); + } + if (authentication.getPrincipal() instanceof Jwt) { + Jwt jwt = (Jwt) authentication.getPrincipal(); + return Optional.ofNullable(jwt.getClaimAsString("preferred_username")); + } + return Optional.empty(); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/core/exception/AccessForbiddenException.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/core/exception/AccessForbiddenException.java new file mode 100644 index 00000000..b9e69b9d --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/core/exception/AccessForbiddenException.java @@ -0,0 +1,34 @@ +package de.tum.in.www1.hephaestus.core.exception; + +import java.io.Serial; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Generic unchecked exception for access forbidden (i.e. 403) errors. + */ +@ResponseStatus(HttpStatus.FORBIDDEN) +public class AccessForbiddenException extends RuntimeException { + + public static final String NOT_ALLOWED = "You are not allowed to access this resource"; + + @Serial + private static final long serialVersionUID = 1L; + + public AccessForbiddenException() { + super(NOT_ALLOWED); + } + + public AccessForbiddenException(String message) { + super(message); + } + + public AccessForbiddenException(Throwable cause) { + super(NOT_ALLOWED, cause); + } + + public AccessForbiddenException(String entityType, long entityId) { + super("You are not allowed to access the " + entityType + " with id " + entityId); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/core/exception/EntityNotFoundException.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/core/exception/EntityNotFoundException.java new file mode 100644 index 00000000..8c2b34fa --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/core/exception/EntityNotFoundException.java @@ -0,0 +1,29 @@ +package de.tum.in.www1.hephaestus.core.exception; + +import java.io.Serial; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class EntityNotFoundException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + public EntityNotFoundException() { + super(); + } + + public EntityNotFoundException(String message) { + super(message); + } + + public EntityNotFoundException(String entityName, Long entityId) { + super(entityName + " with id: \"" + entityId + "\" does not exist"); + } + + public EntityNotFoundException(String entityName, String entityIdentifier) { + super(entityName + " with identifier: \"" + entityIdentifier + "\" does not exist"); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java index 3da3833f..4cf3312b 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java @@ -1,5 +1,6 @@ package de.tum.in.www1.hephaestus.gitprovider.user; +import de.tum.in.www1.hephaestus.SecurityUtils; import java.util.List; import java.util.Optional; import java.util.Set; @@ -21,7 +22,7 @@ public interface UserRepository extends JpaRepository { SELECT u FROM User u LEFT JOIN FETCH u.mergedPullRequests - WHERE u.login = :login + WHERE u.login ILIKE :login """) Optional findByLoginWithEagerMergedPullRequests(@Param("login") String login); @@ -69,4 +70,12 @@ NOT EXISTS (SELECT l ) """) Set findAllContributingToTeam(@Param("teamId") Long teamId); + + /** + * @return existing user object by current user login + */ + default Optional getCurrentUser() { + var currentUserLogin = SecurityUtils.getCurrentUserLogin(); + return currentUserLogin.map(this::findByLogin).orElse(Optional.empty()); + } } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/Message.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/Message.java index 1e1a70ad..aff7abe5 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/Message.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/Message.java @@ -38,6 +38,6 @@ public class Message { private Session session; public enum MessageSender { - LLM, USER + MENTOR, USER } } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageController.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageController.java index cc22f94e..49eb131e 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageController.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageController.java @@ -1,5 +1,7 @@ package de.tum.in.www1.hephaestus.mentor.message; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -8,23 +10,56 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; +import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository; +import de.tum.in.www1.hephaestus.mentor.session.SessionRepository; +import de.tum.in.www1.hephaestus.mentor.session.SessionService; @RestController -@RequestMapping("/mentor/message") +@RequestMapping("/mentor/sessions") public class MessageController { + @Autowired + private UserRepository userRepository; + + @Autowired + private SessionRepository sessionRepository; + + @Autowired + private SessionService sessionService; + @Autowired private MessageService messageService; @GetMapping("/{sessionId}") public ResponseEntity> getMessages(@PathVariable Long sessionId) { + var user = userRepository.getCurrentUser(); + if (user.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + var session = sessionRepository.findById(sessionId); + if (session.isEmpty()) { + return ResponseEntity.notFound().build(); + } + sessionService.checkAccessElseThrow(user.get(), session.get()); + List messages = messageService.getMessagesBySessionId(sessionId); return ResponseEntity.ok(messages); } @PostMapping("/{sessionId}") public ResponseEntity createMessage(@RequestBody String message, @PathVariable Long sessionId) { + var user = userRepository.getCurrentUser(); + if (user.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + var session = sessionRepository.findById(sessionId); + if (session.isEmpty()) { + return ResponseEntity.notFound().build(); + } + sessionService.checkAccessElseThrow(user.get(), session.get()); + MessageDTO createdMessage = messageService.sendMessage(message, sessionId); return ResponseEntity.ok(createdMessage); } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageService.java index 6cacd4cd..77e38614 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageService.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/message/MessageService.java @@ -63,7 +63,7 @@ public MessageDTO sendMessage(String content, Long sessionId) { } Message systemMessage = new Message(); - systemMessage.setSender(MessageSender.LLM); + systemMessage.setSender(MessageSender.MENTOR); systemMessage.setContent(systemResponse); systemMessage.setSession(currentSession); diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionController.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionController.java index 3c1362e5..249e27fc 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionController.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionController.java @@ -1,31 +1,45 @@ package de.tum.in.www1.hephaestus.mentor.session; +import java.util.List; + import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.GetMapping; -import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository; + @RestController -@RequestMapping("/mentor/session") +@RequestMapping("/mentor/sessions") public class SessionController { + @Autowired + private UserRepository userRepository; + @Autowired private SessionService sessionService; @GetMapping - public ResponseEntity> getSessions(@RequestParam String login) { - List sessions = sessionService.findAllSessionsByUser(login); + public ResponseEntity> getAllSessions() { + var user = userRepository.getCurrentUser(); + if (user.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + List sessions = sessionService.findAllSessionsByUser(user.get()); return ResponseEntity.ok(sessions); } @PostMapping - public ResponseEntity createSession(@RequestBody String login) { - SessionDTO session = sessionService.createSession(login); + public ResponseEntity createNewSession() { + var user = userRepository.getCurrentUser(); + if (user.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + SessionDTO session = sessionService.createSession(user.get()); return ResponseEntity.ok(session); } } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionDTO.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionDTO.java index 5df6ff6c..4d6677a2 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionDTO.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionDTO.java @@ -2,22 +2,16 @@ import org.springframework.lang.NonNull; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.in.www1.hephaestus.mentor.message.MessageDTO; -import java.util.List; import java.time.OffsetDateTime; @JsonInclude(JsonInclude.Include.NON_EMPTY) public record SessionDTO( @NonNull Long id, - @NonNull List messages, - @NonNull String userLogin, @NonNull OffsetDateTime createdAt) { public static SessionDTO fromSession(Session session) { return new SessionDTO( session.getId(), - session.getMessages().stream().map(MessageDTO::fromMessage).toList(), - session.getUser().getLogin(), session.getCreatedAt()); } } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionRepository.java index c73d35ed..62f6dc6f 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionRepository.java @@ -1,12 +1,12 @@ package de.tum.in.www1.hephaestus.mentor.session; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; - -import java.util.List; +import de.tum.in.www1.hephaestus.gitprovider.user.User; @Repository public interface SessionRepository extends JpaRepository { - List findByUserLogin(String login); + List findByUser(User user); } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionService.java index cb7e279f..e84428f5 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionService.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/mentor/session/SessionService.java @@ -1,21 +1,26 @@ package de.tum.in.www1.hephaestus.mentor.session; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository; import java.util.Optional; import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import de.tum.in.www1.hephaestus.core.exception.AccessForbiddenException; +import de.tum.in.www1.hephaestus.gitprovider.user.User; @Service public class SessionService { + @Autowired private SessionRepository sessionRepository; - @Autowired - private UserRepository userRepository; - public List findAllSessionsByUser(String login) { - List sessions = sessionRepository.findByUserLogin(login); + public void checkAccessElseThrow(User user, Session session) { + if (!session.getUser().getId().equals(user.getId())) { + throw new AccessForbiddenException("Session", session.getId()); + } + } + + public List findAllSessionsByUser(User user) { + List sessions = sessionRepository.findByUser(user); return sessions.stream().map(SessionDTO::fromSession).toList(); } @@ -23,14 +28,9 @@ public Optional findSessionById(Long sessionId) { return sessionRepository.findById(sessionId).map(SessionDTO::fromSession); } - public SessionDTO createSession(String login) { - var user = userRepository.findByLogin(login); - if (user.isEmpty()) { - return null; - } - + public SessionDTO createSession(User user) { Session session = new Session(); - session.setUser(user.get()); + session.setUser(user); return SessionDTO.fromSession(sessionRepository.save(session)); } diff --git a/webapp/src/app/core/modules/openapi/api/message.service.ts b/webapp/src/app/core/modules/openapi/api/message.service.ts index 76ac0c9a..a3b353bb 100644 --- a/webapp/src/app/core/modules/openapi/api/message.service.ts +++ b/webapp/src/app/core/modules/openapi/api/message.service.ts @@ -157,7 +157,7 @@ export class MessageService implements MessageServiceInterface { } } - let localVarPath = `/mentor/message/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + let localVarPath = `/mentor/sessions/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, @@ -221,7 +221,7 @@ export class MessageService implements MessageServiceInterface { } } - let localVarPath = `/mentor/message/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; + let localVarPath = `/mentor/sessions/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}`; return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, diff --git a/webapp/src/app/core/modules/openapi/api/session.service.ts b/webapp/src/app/core/modules/openapi/api/session.service.ts index 5e734cfe..fe805e08 100644 --- a/webapp/src/app/core/modules/openapi/api/session.service.ts +++ b/webapp/src/app/core/modules/openapi/api/session.service.ts @@ -96,17 +96,13 @@ export class SessionService implements SessionServiceInterface { } /** - * @param body * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public createSession(body: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public createSession(body: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public createSession(body: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public createSession(body: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { - if (body === null || body === undefined) { - throw new Error('Required parameter body was null or undefined when calling createSession.'); - } + public createNewSession(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public createNewSession(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createNewSession(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public createNewSession(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarHeaders = this.defaultHeaders; @@ -133,15 +129,6 @@ export class SessionService implements SessionServiceInterface { } - // to determine the Content-Type header - const consumes: string[] = [ - 'application/json' - ]; - const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); - if (httpContentTypeSelected !== undefined) { - localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); - } - let responseType_: 'text' | 'json' | 'blob' = 'json'; if (localVarHttpHeaderAcceptSelected) { if (localVarHttpHeaderAcceptSelected.startsWith('text')) { @@ -153,11 +140,10 @@ export class SessionService implements SessionServiceInterface { } } - let localVarPath = `/mentor/session`; + let localVarPath = `/mentor/sessions`; return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, - body: body, responseType: responseType_, withCredentials: this.configuration.withCredentials, headers: localVarHeaders, @@ -169,23 +155,13 @@ export class SessionService implements SessionServiceInterface { } /** - * @param login * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getSessions(login: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getSessions(login: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; - public getSessions(login: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; - public getSessions(login: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { - if (login === null || login === undefined) { - throw new Error('Required parameter login was null or undefined when calling getSessions.'); - } - - let localVarQueryParameters = new HttpParams({encoder: this.encoder}); - if (login !== undefined && login !== null) { - localVarQueryParameters = this.addToHttpParams(localVarQueryParameters, - login, 'login'); - } + public getAllSessions(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getAllSessions(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllSessions(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getAllSessions(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarHeaders = this.defaultHeaders; @@ -223,11 +199,10 @@ export class SessionService implements SessionServiceInterface { } } - let localVarPath = `/mentor/session`; + let localVarPath = `/mentor/sessions`; return this.httpClient.request>('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, - params: localVarQueryParameters, responseType: responseType_, withCredentials: this.configuration.withCredentials, headers: localVarHeaders, diff --git a/webapp/src/app/core/modules/openapi/api/session.serviceInterface.ts b/webapp/src/app/core/modules/openapi/api/session.serviceInterface.ts index 1ff89f97..7856fdde 100644 --- a/webapp/src/app/core/modules/openapi/api/session.serviceInterface.ts +++ b/webapp/src/app/core/modules/openapi/api/session.serviceInterface.ts @@ -27,15 +27,13 @@ export interface SessionServiceInterface { /** * * - * @param body */ - createSession(body: string, extraHttpRequestParams?: any): Observable; + createNewSession(extraHttpRequestParams?: any): Observable; /** * * - * @param login */ - getSessions(login: string, extraHttpRequestParams?: any): Observable>; + getAllSessions(extraHttpRequestParams?: any): Observable>; } diff --git a/webapp/src/app/core/modules/openapi/model/message.ts b/webapp/src/app/core/modules/openapi/model/message.ts index 94f6d0fc..10c6bc0a 100644 --- a/webapp/src/app/core/modules/openapi/model/message.ts +++ b/webapp/src/app/core/modules/openapi/model/message.ts @@ -19,9 +19,9 @@ export interface Message { sessionId: number; } export namespace Message { - export type SenderEnum = 'LLM' | 'USER'; + export type SenderEnum = 'MENTOR' | 'USER'; export const SenderEnum = { - Llm: 'LLM' as SenderEnum, + Mentor: 'MENTOR' as SenderEnum, User: 'USER' as SenderEnum }; } diff --git a/webapp/src/app/core/modules/openapi/model/session.ts b/webapp/src/app/core/modules/openapi/model/session.ts index 1b149938..1da18674 100644 --- a/webapp/src/app/core/modules/openapi/model/session.ts +++ b/webapp/src/app/core/modules/openapi/model/session.ts @@ -9,13 +9,10 @@ * https://openapi-generator.tech * Do not edit the class manually. */ -import { Message } from './message'; export interface Session { id: number; - messages: Array; - userLogin: string; createdAt: string; } diff --git a/webapp/src/app/mentor/chat-input/chat-input.component.html b/webapp/src/app/mentor/chat-input/chat-input.component.html new file mode 100644 index 00000000..9bc2b8a0 --- /dev/null +++ b/webapp/src/app/mentor/chat-input/chat-input.component.html @@ -0,0 +1,19 @@ +
+ + +
diff --git a/webapp/src/app/mentor/chat-input/chat-input.component.ts b/webapp/src/app/mentor/chat-input/chat-input.component.ts new file mode 100644 index 00000000..25531134 --- /dev/null +++ b/webapp/src/app/mentor/chat-input/chat-input.component.ts @@ -0,0 +1,34 @@ +import { Component, input, output, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { LucideAngularModule, Send } from 'lucide-angular'; +import { HlmCardModule } from '@spartan-ng/ui-card-helm'; +import { HlmButtonModule } from '@spartan-ng/ui-button-helm'; +import { HlmSpinnerComponent } from '@spartan-ng/ui-spinner-helm'; +import { HlmInputDirective } from '@spartan-ng/ui-input-helm'; + +@Component({ + selector: 'app-chat-input', + templateUrl: './chat-input.component.html', + standalone: true, + imports: [CommonModule, HlmButtonModule, HlmSpinnerComponent, FormsModule, HlmCardModule, HlmInputDirective, LucideAngularModule] +}) +export class ChatInputComponent { + protected Send = Send; + + isSending = input.required(); + message = signal(''); + sendMessage = output(); + + onSendMessage() { + if (this.isSending() || !this.message()) { + return; + } + + this.sendMessage.emit(this.message()); + setTimeout(() => { + this.message.set(''); + }); + } +} diff --git a/webapp/src/app/mentor/chat-input/chat-input.stories.ts b/webapp/src/app/mentor/chat-input/chat-input.stories.ts new file mode 100644 index 00000000..a7259e82 --- /dev/null +++ b/webapp/src/app/mentor/chat-input/chat-input.stories.ts @@ -0,0 +1,21 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { ChatInputComponent } from './chat-input.component'; + +const meta: Meta = { + component: ChatInputComponent, + tags: ['autodocs'], + args: { + isSending: false + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Sending: Story = { + args: { + isSending: true + } +}; diff --git a/webapp/src/app/mentor/first-session-card/first-session-card.component.html b/webapp/src/app/mentor/first-session-card/first-session-card.component.html index a48804f4..8771662e 100644 --- a/webapp/src/app/mentor/first-session-card/first-session-card.component.html +++ b/webapp/src/app/mentor/first-session-card/first-session-card.component.html @@ -1,13 +1,12 @@ -
-
- +
+
+
-

- Meet Your Personal AI Mentor – designed to help you grow faster through focused and reflective learning sessions. Click below to begin! -

+
+

Meet Your Personal AI Mentor

+

Designed to help you grow and stay focused. Click below to begin!

+
- Start First Session +
diff --git a/webapp/src/app/mentor/first-session-card/first-session-card.component.ts b/webapp/src/app/mentor/first-session-card/first-session-card.component.ts index 01b6cfe0..e914cbea 100644 --- a/webapp/src/app/mentor/first-session-card/first-session-card.component.ts +++ b/webapp/src/app/mentor/first-session-card/first-session-card.component.ts @@ -12,9 +12,5 @@ export class FirstSessionCardComponent { protected Plus = Plus; protected BotMessageSquare = BotMessageSquare; - createSession = output(); - - handleCreateSession(): void { - this.createSession.emit(); - } + createNewSession = output(); } diff --git a/webapp/src/app/mentor/first-session-card/first-session-card.stories.ts b/webapp/src/app/mentor/first-session-card/first-session-card.stories.ts new file mode 100644 index 00000000..970ebcfe --- /dev/null +++ b/webapp/src/app/mentor/first-session-card/first-session-card.stories.ts @@ -0,0 +1,13 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { FirstSessionCardComponent } from './first-session-card.component'; + +const meta: Meta = { + component: FirstSessionCardComponent, + tags: ['autodocs'], + args: {} +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/webapp/src/app/mentor/input/input.component.html b/webapp/src/app/mentor/input/input.component.html deleted file mode 100644 index f2fc332e..00000000 --- a/webapp/src/app/mentor/input/input.component.html +++ /dev/null @@ -1,21 +0,0 @@ -
- - - - -
diff --git a/webapp/src/app/mentor/input/input.component.ts b/webapp/src/app/mentor/input/input.component.ts deleted file mode 100644 index 7bfa72eb..00000000 --- a/webapp/src/app/mentor/input/input.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, output } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { HlmCardModule } from '@spartan-ng/ui-card-helm'; - -import { HlmButtonModule } from '@spartan-ng/ui-button-helm'; -import { LucideAngularModule, Send } from 'lucide-angular'; - -@Component({ - selector: 'app-chat-input', - templateUrl: './input.component.html', - standalone: true, - imports: [CommonModule, HlmButtonModule, FormsModule, HlmCardModule, LucideAngularModule] -}) -export class InputComponent { - protected Send = Send; - - messageSent = output(); - messageText = ''; - - onSend() { - if (this.messageText.trim() !== '') { - this.messageSent.emit(this.messageText); - setTimeout(() => { - this.messageText = ''; - }, 0); - } - } -} diff --git a/webapp/src/app/mentor/input/input.stories.ts b/webapp/src/app/mentor/input/input.stories.ts deleted file mode 100644 index 5ab5cb5b..00000000 --- a/webapp/src/app/mentor/input/input.stories.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular'; -import { InputComponent } from './input.component'; - -const meta: Meta = { - component: InputComponent, - tags: ['autodocs'], - args: { - messageText: '' - } -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: `` - }) -}; diff --git a/webapp/src/app/mentor/mentor.component.html b/webapp/src/app/mentor/mentor.component.html index 9a022492..21212463 100644 --- a/webapp/src/app/mentor/mentor.component.html +++ b/webapp/src/app/mentor/mentor.component.html @@ -1,28 +1,39 @@ -@if (isLoading()) { -
- -
-} @else { -
- @if (sessions().length === 0) { -
- +
+
+ @if (sessions.isLoading()) { + + } @else if (sessions.isError()) { +
+ +

Something went wrong...

+

Failed to load sessions. Please try again later.

+
+ } + @if (sessions.isSuccess()) { +
+ @if (!sessions.data()) { +
+ +
+ } @else { + +
+ @if (selectedSessionId() !== undefined) { + + + } +
+ }
- } @else { - - - @if (selectedSession()) { -
- - -
- } }
-} +
diff --git a/webapp/src/app/mentor/mentor.component.ts b/webapp/src/app/mentor/mentor.component.ts index 6b752030..01a06a51 100644 --- a/webapp/src/app/mentor/mentor.component.ts +++ b/webapp/src/app/mentor/mentor.component.ts @@ -1,137 +1,66 @@ import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { lastValueFrom } from 'rxjs'; -import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; +import { injectMutation, injectQuery, injectQueryClient } from '@tanstack/angular-query-experimental'; import { SessionsCardComponent } from './sessions-card/sessions-card.component'; import { MessagesComponent } from './messages/messages.component'; -import { InputComponent } from './input/input.component'; -import { SecurityStore } from '@app/core/security/security-store.service'; -import { Message, Session } from '@app/core/modules/openapi'; +import { ChatInputComponent } from './chat-input/chat-input.component'; +import { LucideAngularModule, CircleX } from 'lucide-angular'; import { MessageService, SessionService } from '@app/core/modules/openapi'; import { HlmButtonModule } from '@spartan-ng/ui-button-helm'; import { HlmSpinnerComponent } from '@spartan-ng/ui-spinner-helm'; import { FirstSessionCardComponent } from './first-session-card/first-session-card.component'; +import { HlmAlertModule } from '@spartan-ng/ui-alert-helm'; @Component({ selector: 'app-mentor', templateUrl: './mentor.component.html', standalone: true, - imports: [CommonModule, FirstSessionCardComponent, HlmSpinnerComponent, SessionsCardComponent, MessagesComponent, InputComponent, HlmButtonModule] + imports: [ + CommonModule, + FirstSessionCardComponent, + HlmSpinnerComponent, + SessionsCardComponent, + MessagesComponent, + ChatInputComponent, + HlmButtonModule, + HlmAlertModule, + LucideAngularModule + ] }) export class MentorComponent { - securityStore = inject(SecurityStore); + protected CircleX = CircleX; + messageService = inject(MessageService); sessionService = inject(SessionService); - signedIn = this.securityStore.signedIn; - user = this.securityStore.loadedUser; - - messageHistory = signal([]); - selectedSession = signal(null); - sessions = signal([]); - isLoading = signal(true); + selectedSessionId = signal(undefined); - latestMessageContent = ''; + queryClient = injectQueryClient(); - protected query_sessions = injectQuery(() => ({ - enabled: this.signedIn(), - queryKey: ['sessions', { login: this.user()?.username }], - queryFn: async () => { - const username = this.user()?.username; - if (!username) { - throw new Error('User is not logged in or username is undefined.'); - } - const sessions = await lastValueFrom(this.sessionService.getSessions(username)); - if (sessions.length > 0 && this.selectedSession() == null) { - this.selectedSession.set(sessions.slice(-1)[0]); - } - this.sessions.set(sessions.reverse()); - this.isLoading.set(false); - return sessions; - } + sessions = injectQuery(() => ({ + queryKey: ['sessions'], + queryFn: async () => lastValueFrom(this.sessionService.getAllSessions()) })); - handleSessionSelect(sessionId: number): void { - const session = this.sessions().find((s) => s.id === sessionId); - if (session) { - this.selectedSession.set(session); - this.query_sessions.refetch(); - } - } - - handleCreateSession(): void { - this.createSession.mutate(); - } - - protected createSession = injectMutation(() => ({ - mutationFn: async () => { - const username = this.user()?.username; - if (!username) { - throw new Error('User is not logged in or username is undefined.'); - } - await lastValueFrom(this.sessionService.createSession(username)); - }, - onSuccess: async () => { - await this.query_sessions.refetch(); - const sessions = this.sessions(); - if (sessions.length > 0) { - const newSession = sessions[0]; - this.selectedSession.set(newSession); - } - } + selectedSessionMessages = injectQuery(() => ({ + enabled: !!this.selectedSessionId(), + queryKey: ['sessions', this.selectedSessionId()], + queryFn: async () => lastValueFrom(this.messageService.getMessages(this.selectedSessionId()!)) })); - protected query_messages = injectQuery(() => ({ - enabled: !!this.selectedSession, - queryKey: ['messages', { sessionId: this.selectedSession()?.id }], - queryFn: async () => { - const selectedSessionId = this.selectedSession()?.id; - if (selectedSessionId == null) { - throw new Error('No session selected!'); - } - const loadedMessages = await lastValueFrom(this.messageService.getMessages(selectedSessionId)); - this.messageHistory.set(loadedMessages); - return loadedMessages; + createNewSession = injectMutation(() => ({ + mutationFn: async () => lastValueFrom(this.sessionService.createNewSession()), + onSuccess: (session) => { + this.queryClient.invalidateQueries({ queryKey: ['sessions'] }); + this.selectedSessionId.set(session.id); } })); - protected sendMessage = injectMutation(() => ({ - queryKey: ['messages', 'create'], - mutationFn: async ({ sessionId }: { sessionId: number }) => { - if (!this.selectedSession) { - throw new Error('No session selected!'); - } - await lastValueFrom(this.messageService.createMessage(sessionId, this.latestMessageContent)); - }, + sendMessage = injectMutation(() => ({ + mutationFn: async ({ sessionId, message }: { sessionId: number; message: string }) => lastValueFrom(this.messageService.createMessage(sessionId, message)), onSuccess: () => { - this.query_messages.refetch(); + this.queryClient.invalidateQueries({ queryKey: ['sessions', this.selectedSessionId()] }); } })); - - handleSendMessage(content: string): void { - if (!this.selectedSession) { - console.error('No session selected!'); - return; - } - - const selectedSessionId = this.selectedSession()?.id; - if (selectedSessionId == null) { - console.error('No session selected!'); - return; - } else { - // show the user message directly after sending - const userMessage: Message = { - id: Math.random(), // temporary id until the message is sent - sessionId: selectedSessionId, - sender: 'USER', - content: content, - sentAt: new Date().toISOString() - }; - - this.messageHistory.set([...this.messageHistory(), userMessage]); - - this.latestMessageContent = content; - this.sendMessage.mutate({ sessionId: selectedSessionId }); - } - } } diff --git a/webapp/src/app/mentor/messages/messages.component.html b/webapp/src/app/mentor/messages/messages.component.html index 8823e1b3..b96046cd 100644 --- a/webapp/src/app/mentor/messages/messages.component.html +++ b/webapp/src/app/mentor/messages/messages.component.html @@ -1,40 +1,31 @@ -
- @for (message of messageHistory(); track message.id) { -
-
- @if (message.sender === 'USER') { -
- - - - {{ user()?.name?.slice(0, 2)?.toUpperCase() ?? '?' }} - - -
- } - - @if (message.sender === 'LLM') { -
-
- +
+ @if (messages().length > 0) { + @for (message of messages(); track message.id) { +
+
+ @if (message.sender === Message.SenderEnum.Mentor) { +
+
+ +
+ } +
+
+

{{ message.content }}

+
+ {{ message.sender === Message.SenderEnum.User ? 'You' : 'AI Mentor' }} · {{ message.sentAt | date: 'shortTime' }}
- } - -
-
-

{{ message.content }}

-
- {{ message.sender === 'USER' ? 'You' : 'AI Mentor' }} · {{ message.sentAt | date: 'shortTime' }}
-
+ } + } @else { +

What can I help with?

}
diff --git a/webapp/src/app/mentor/messages/messages.component.ts b/webapp/src/app/mentor/messages/messages.component.ts index 30470395..c3e92ca9 100644 --- a/webapp/src/app/mentor/messages/messages.component.ts +++ b/webapp/src/app/mentor/messages/messages.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, input, OnInit, AfterViewChecked, ElementRef, ViewChild } from '@angular/core'; +import { Component, inject, input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LucideAngularModule, BotMessageSquare } from 'lucide-angular'; import { HlmAvatarModule } from '@spartan-ng/ui-avatar-helm'; @@ -11,32 +11,10 @@ import { Message } from '@app/core/modules/openapi'; standalone: true, imports: [CommonModule, LucideAngularModule, HlmAvatarModule] }) -export class MessagesComponent implements OnInit, AfterViewChecked { +export class MessagesComponent { protected BotMessageSquare = BotMessageSquare; + protected Message = Message; securityStore = inject(SecurityStore); - user = this.securityStore.loadedUser; - signedIn = this.securityStore.signedIn; - - messageHistory = input([]); - - @ViewChild('chatMessagesContainer') private chatMessagesContainer!: ElementRef; - - ngOnInit() { - this.scrollToBottom(); - } - - ngAfterViewChecked() { - this.scrollToBottom(); - } - - private scrollToBottom(): void { - try { - if (this.chatMessagesContainer) { - this.chatMessagesContainer.nativeElement.scrollTop = this.chatMessagesContainer.nativeElement.scrollHeight; - } - } catch (err) { - console.error(err); - } - } + messages = input([]); } diff --git a/webapp/src/app/mentor/messages/messages.stories.ts b/webapp/src/app/mentor/messages/messages.stories.ts new file mode 100644 index 00000000..fa307656 --- /dev/null +++ b/webapp/src/app/mentor/messages/messages.stories.ts @@ -0,0 +1,58 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { MessagesComponent } from './messages.component'; +import { Message } from '@app/core/modules/openapi'; + +const meta: Meta = { + component: MessagesComponent, + tags: ['autodocs'], + args: { + messages: [ + { + id: 1, + sentAt: '2024-12-05T10:15:00Z', + sender: Message.SenderEnum.Mentor, + content: 'Hello! How can I assist you today?', + sessionId: 101 + }, + { + id: 2, + sentAt: '2024-12-05T10:16:30Z', + sender: Message.SenderEnum.User, + content: 'I need help with understanding my recent order.', + sessionId: 101 + }, + { + id: 3, + sentAt: '2024-12-05T10:17:00Z', + sender: Message.SenderEnum.Mentor, + content: 'Sure! Could you provide your order ID?', + sessionId: 101 + }, + { + id: 4, + sentAt: '2024-12-05T10:17:45Z', + sender: Message.SenderEnum.User, + content: 'The order ID is #12345. I’m looking for the details.', + sessionId: 101 + }, + { + id: 5, + sentAt: '2024-12-05T10:18:10Z', + sender: Message.SenderEnum.Mentor, + content: "Got it! Please hold on while I fetch your details. Thank you for your patience. :) I'll be back in a moment...", + sessionId: 101 + } + ] + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { + messages: [] + } +}; diff --git a/webapp/src/app/mentor/sessions-card/sessions-card.component.html b/webapp/src/app/mentor/sessions-card/sessions-card.component.html index 43d7f69b..81723837 100644 --- a/webapp/src/app/mentor/sessions-card/sessions-card.component.html +++ b/webapp/src/app/mentor/sessions-card/sessions-card.component.html @@ -1,36 +1,16 @@ -
-
- - - New Session - +
+ + @if ((sessions()?.length ?? 0) > 0) {
-

Past Sessions

-
    - @for (session of sessions(); track session.id) { -
  • - {{ session.createdAt | date: 'short' }} -
  • - } -
+

Past Sessions

+ @for (session of sessions(); track session.id) { + + }
-
+ }
diff --git a/webapp/src/app/mentor/sessions-card/sessions-card.component.ts b/webapp/src/app/mentor/sessions-card/sessions-card.component.ts index 5f975663..41c7a074 100644 --- a/webapp/src/app/mentor/sessions-card/sessions-card.component.ts +++ b/webapp/src/app/mentor/sessions-card/sessions-card.component.ts @@ -1,31 +1,22 @@ -import { Component, input, output } from '@angular/core'; +import { Component, input, model, output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LucideAngularModule, Plus } from 'lucide-angular'; import { HlmButtonModule } from '@spartan-ng/ui-button-helm'; +import { BrnToggleDirective } from '@spartan-ng/ui-toggle-brain'; import { Session } from '@app/core/modules/openapi'; +import { HlmToggleDirective } from '@spartan-ng/ui-toggle-helm'; +import { HlmCardDirective } from '@spartan-ng/ui-card-helm'; @Component({ standalone: true, selector: 'app-sessions-card', templateUrl: './sessions-card.component.html', - imports: [CommonModule, LucideAngularModule, HlmButtonModule] + imports: [CommonModule, LucideAngularModule, BrnToggleDirective, HlmToggleDirective, HlmButtonModule, HlmCardDirective] }) export class SessionsCardComponent { protected Plus = Plus; sessions = input(); - activeSessionId = input(); - - sessionSelected = output(); - createSession = output(); - - handleSelectSession(sessionId: number): void { - if (this.activeSessionId() && this.activeSessionId() !== sessionId) { - this.sessionSelected.emit(sessionId); - } - } - - handleCreateSession(): void { - this.createSession.emit(); - } + selectedSessionId = model(); + createNewSession = output(); } diff --git a/webapp/src/app/mentor/sessions-card/sessions-card.stories.ts b/webapp/src/app/mentor/sessions-card/sessions-card.stories.ts new file mode 100644 index 00000000..1f3aa9f5 --- /dev/null +++ b/webapp/src/app/mentor/sessions-card/sessions-card.stories.ts @@ -0,0 +1,43 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { SessionsCardComponent } from './sessions-card.component'; + +const meta: Meta = { + component: SessionsCardComponent, + tags: ['autodocs'], + args: { + sessions: [ + { + id: 1, + createdAt: '2024-12-05T10:15:00Z' + }, + { + id: 2, + createdAt: '2024-12-05T10:16:30Z' + }, + { + id: 3, + createdAt: '2024-12-05T10:17:00Z' + }, + { + id: 4, + createdAt: '2024-12-05T10:17:45Z' + }, + { + id: 5, + createdAt: '2024-12-05T10:18:10Z' + } + ] + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { + sessions: [], + selectedSessionId: undefined + } +};