diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java new file mode 100644 index 0000000..25dbf3e --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java @@ -0,0 +1,83 @@ +package com.smartjam.smartjamapi.entity; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.*; + +import com.smartjam.smartjamapi.enums.RefreshTokenStatus; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +/** + * JPA entity representing a refresh token record in the SmartJam platform. + * + *

Mapped to the {@code refresh_tokens} database table. Rather than storing the raw token string, only a + * cryptographic hash of the token is persisted in {@link #tokenHash}. This prevents token replay attacks in the event + * of a database compromise. + * + *

The lifecycle of a token is tracked via the {@link #status} field. Tokens start as + * {@link RefreshTokenStatus#ACTIVE} and transition to {@link RefreshTokenStatus#INACTIVE} upon revocation or expiry. + * + *

Persistence exceptions: Attempting to persist or merge an instance with a duplicate {@code tokenHash}, a + * {@code null} required field, or a value that violates a column constraint will cause the JPA provider to throw a + * {@link jakarta.persistence.PersistenceException} (typically wrapped by Spring as + * {@link org.springframework.dao.DataIntegrityViolationException}). + */ +@Entity +@Table(name = "refresh_tokens") +@Getter +@Setter +@NoArgsConstructor +public class RefreshTokenEntity { + + /** Unique identifier for the refresh token record, generated automatically as a UUID. */ + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** + * Cryptographic hash (e.g. SHA-256 hex digest) of the opaque refresh token string. Stored instead of the raw token + * to mitigate replay attacks from a compromised database. Length is 64 characters, sufficient for a SHA-256 hex + * digest. + */ + @Column(nullable = false, unique = true, length = 64) + private String tokenHash; + + /** + * The user to whom this refresh token belongs. Loaded lazily to avoid unnecessary joins when only token metadata is + * needed. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + /** The point in time at which this refresh token expires and must no longer be accepted. */ + @Column(nullable = false, name = "expires_at") + private Instant expiresAt; + + /** + * The current lifecycle status of this refresh token. Defaults to {@link RefreshTokenStatus#ACTIVE} upon creation. + * Persisted as a {@link String} in the database. + */ + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private RefreshTokenStatus status = RefreshTokenStatus.ACTIVE; + + /** + * Timestamp of when this refresh token record was first created. Set automatically by Hibernate on insert and never + * updated afterwards. + */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + /** + * Timestamp of the most recent update to this refresh token record. Updated automatically by Hibernate on every + * merge/flush. + */ + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java new file mode 100644 index 0000000..52bf5e3 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java @@ -0,0 +1,98 @@ +package com.smartjam.smartjamapi.entity; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; + +import com.smartjam.smartjamapi.enums.Role; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +/** + * JPA entity representing a registered user in the SmartJam platform. + * + *

Mapped to the {@code users} database table. The primary key is a UUID generated automatically by the persistence + * provider. Equality and hash-code are based solely on {@link #id} to ensure correct behavior in JPA-managed + * collections and during entity detachment/reattachment cycles. + * + *

The {@code email} field is the unique login identifier. {@code nickname} is a non-unique display name. Passwords + * are never stored in plain text — only the hashed value is persisted in {@code passwordHash}. + * + *

Persistence exceptions: Attempting to persist or merge an instance with a duplicate {@code email}, a + * {@code null} required field, or a value that violates a column constraint will cause the underlying JPA provider to + * throw a {@link jakarta.persistence.PersistenceException} (typically wrapped by Spring as + * {@link org.springframework.dao.DataIntegrityViolationException}). + */ +@Setter +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +@Entity +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class UserEntity { + + /** Unique identifier for the user, generated automatically as a UUID. */ + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @EqualsAndHashCode.Include + private UUID id; + + /** Non-unique display name (nickname) of the user shown in the UI. Must not be {@code null}. */ + @Column(nullable = false) + private String nickname; + + /** + * The user's email address, used as the unique login identifier. Must be a valid email format, non-null, and unique + * across all users. + */ + @Column(nullable = false, unique = true, length = 255) + @Email + private String email; + + /** Bcrypt (or equivalent) hash of the user's password. The plain-text password is never stored. */ + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + /** Optional first name of the user. */ + @Column(name = "first_name") + private String firstName; + + /** Optional last name of the user. */ + @Column(name = "last_name") + private String lastName; + + /** Optional URL pointing to the user's avatar image. Supports long URLs (up to 2048 characters). */ + @Column(name = "avatar_url", length = 2048) + private String avatarUrl; + + /** + * The role of the user, determining their permissions within the platform. Defaults to {@link Role#STUDENT} for all + * newly created users. Persisted as a {@link String} in the database. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role = Role.STUDENT; + + /** Optional Firebase Cloud Messaging token used to send push notifications to the user's device. */ + @Column(name = "fcm_token") + private String fcmToken; + + /** + * Timestamp of when the user record was first created. Set automatically by Hibernate on insert and never updated + * afterwards. + */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + /** + * Timestamp of the most recent update to the user record. Updated automatically by Hibernate on every merge/flush. + */ + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/RefreshTokenStatus.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/RefreshTokenStatus.java new file mode 100644 index 0000000..862708b --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/RefreshTokenStatus.java @@ -0,0 +1,17 @@ +package com.smartjam.smartjamapi.enums; + +/** + * Represents the lifecycle status of a refresh token stored in the database. + * + *

Status values are persisted as {@link String} via {@link jakarta.persistence.EnumType#STRING} in the + * {@code refresh_tokens} table. + */ +public enum RefreshTokenStatus { + /** The refresh token is valid and can be used to obtain a new access token. */ + ACTIVE, + /** + * The refresh token is no longer valid and cannot be used for token refresh. This covers tokens that have been + * revoked, used, or expired. + */ + INACTIVE +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/Role.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/Role.java new file mode 100644 index 0000000..8f80849 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/Role.java @@ -0,0 +1,12 @@ +package com.smartjam.smartjamapi.enums; + +/** + * Represents the role of a user within the SmartJam platform. + * + *

Roles are used to control access and permissions across the application. The role value is persisted as a {`@link` + * String} in the database via {`@link` jakarta.persistence.EnumType#STRING}. + */ +public enum Role { + STUDENT, + TEACHER +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..287ea0c --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java @@ -0,0 +1,95 @@ +package com.smartjam.smartjamapi.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.smartjam.smartjamapi.entity.RefreshTokenEntity; +import com.smartjam.smartjamapi.entity.UserEntity; +import com.smartjam.smartjamapi.enums.RefreshTokenStatus; +import org.springframework.dao.DataAccessException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +/** + * Spring Data JPA repository for {@link RefreshTokenEntity} persistence operations. + * + *

In addition to standard CRUD operations inherited from {@link JpaRepository}, this repository provides token-hash + * lookups and bulk JPQL {@code UPDATE} statements for efficient status transitions without loading full entity graphs. + * + *

All {@link Modifying} methods use {@code clearAutomatically = true} and {@code flushAutomatically = true} to + * ensure the persistence context is flushed before and cleared after each bulk update, preventing stale first-level + * cache entries from being read after the operation. + */ +public interface RefreshTokenRepository extends JpaRepository { + + /** + * Finds a refresh token record by the hash of the raw token string. + * + *

+ * + * @param tokenHash the SHA-256 hex digest (or equivalent hash) of the refresh token; must not be {@code null} + * @return an {@link Optional} containing the matching {@link RefreshTokenEntity}, or {@link Optional#empty()} if no + * record with the given hash exists + * @throws IllegalArgumentException if {@code token} is {@code null} + * @throws DataAccessException if a database access error occurs (e.g. connection failure, query execution error) + */ + Optional findByTokenHash(String tokenHash); + + /** + * Updates the status of the refresh token identified by the given token hash. + * + *

This is a bulk JPQL {@code UPDATE} that does not load the entity into the persistence context, making it more + * efficient than a find-then-save pattern. + * + *

+ * + * @param tokenHash the SHA-256 hex digest of the refresh token whose status should be updated; must not be + * {@code null} + * @param status the new {@link RefreshTokenStatus} to set; must not be {@code null} + * @return the number of rows affected by the update (0 or 1) + * @throws IllegalArgumentException if {@code tokenHash} or {@code status} is {@code null} + * @throws org.springframework.dao.DataAccessException if a database access error occurs during the update + * @throws jakarta.persistence.TransactionRequiredException if called outside an active transaction (normally + * prevented by the {@code @Transactional} annotation on this method) + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE RefreshTokenEntity r SET r.status = :status WHERE r.tokenHash = :tokenHash") + int setStatusByTokenHash(@Param("tokenHash") String tokenHash, @Param("status") RefreshTokenStatus status); + + /** + * Bulk-updates the status of all refresh tokens belonging to the given user whose current status matches + * {@code currentStatus}. + * + *

Typically used to invalidate all active tokens for a user on logout or password change, by transitioning them + * from {@link RefreshTokenStatus#ACTIVE} to {@link RefreshTokenStatus#INACTIVE}. + * + *

+ * + * @param user the owner {@link UserEntity} whose tokens should be updated; must not be {@code null} + * @param currentStatus the status that tokens must currently have to be included in the update; must not be + * {@code null} + * @param newStatus the new {@link RefreshTokenStatus} to assign; must not be {@code null} + * @return the number of rows affected by the update + * @throws IllegalArgumentException if any of {@code user}, {@code currentStatus}, or {@code newStatus} is + * {@code null} + * @throws org.springframework.dao.DataAccessException if a database access error occurs during the update + * @throws jakarta.persistence.TransactionRequiredException if called outside an active transaction (normally + * prevented by the {@code @Transactional} annotation on this method) + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE RefreshTokenEntity r + SET r.status = :newStatus + WHERE r.user = :user + AND r.status = :currentStatus +""") + int updateStatusByUserAndCurrentStatus( + @Param("user") UserEntity user, + @Param("currentStatus") RefreshTokenStatus currentStatus, + @Param("newStatus") RefreshTokenStatus newStatus); +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/UserRepository.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/UserRepository.java new file mode 100644 index 0000000..9574072 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/UserRepository.java @@ -0,0 +1,47 @@ +package com.smartjam.smartjamapi.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.smartjam.smartjamapi.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for {@link UserEntity} persistence operations. + * + *

Provides standard CRUD operations inherited from {@link JpaRepository}, as well as derived query methods for + * email-based lookups used during authentication and registration flows. + */ +public interface UserRepository extends JpaRepository { + + /** + * Finds a user by their email address. + * + *

+ * + * @param email the email address to search for; must not be {@code null} + * @return an {@link Optional} containing the matching {@link UserEntity}, or {@link Optional#empty()} if no user + * with the given email exists + * @throws IllegalArgumentException if {@code email} is {@code null} + * @throws org.springframework.dao.DataAccessException if a database access error occurs (e.g. connection failure, + * query execution error); this is a Spring-translated unchecked exception wrapping the underlying + * {@link jakarta.persistence.PersistenceException} + */ + Optional findByEmail(String email); + + /** + * Checks whether a user with the given email address already exists. + * + *

Prefer this method over {@link #findByEmail(String)} when only presence needs to be verified, as it avoids + * loading the full entity. + * + *

+ * + * @param email the email address to check; must not be {@code null} + * @return {@code true} if a user with the specified email exists, {@code false} otherwise + * @throws IllegalArgumentException if {@code email} is {@code null} + * @throws org.springframework.dao.DataAccessException if a database access error occurs (e.g. connection failure, + * query execution error) + */ + boolean existsByEmail(String email); +}