Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p><b>Persistence exceptions:</b> 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")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add table indexes for token lifecycle query paths.

updateStatusByUserAndCurrentStatus(...) filters by user + status; adding an index avoids full scans as token volume grows. An expires_at index also helps cleanup jobs.

⚙️ Proposed schema tuning
-@Table(name = "refresh_tokens")
+@Table(
+        name = "refresh_tokens",
+        indexes = {
+            `@Index`(name = "idx_refresh_tokens_user_status", columnList = "user_id,status"),
+            `@Index`(name = "idx_refresh_tokens_expires_at", columnList = "expires_at")
+        })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Table(name = "refresh_tokens")
`@Entity`
`@Table`(
name = "refresh_tokens",
indexes = {
`@Index`(name = "idx_refresh_tokens_user_status", columnList = "user_id,status"),
`@Index`(name = "idx_refresh_tokens_expires_at", columnList = "expires_at")
})
`@Getter`
`@Setter`
`@EqualsAndHashCode`(onlyExplicitlyIncluded = true)
`@NoArgsConstructor`
public class RefreshTokenEntity {
`@Id`
`@GeneratedValue`(strategy = GenerationType.UUID)
`@EqualsAndHashCode.Include`
private UUID id;
`@Column`(nullable = false, unique = true, length = 64)
private String tokenHash;
`@ManyToOne`(fetch = FetchType.LAZY)
`@JoinColumn`(name = "user_id", nullable = false)
private UserEntity user;
`@Column`(nullable = false)
private LocalDateTime expiresAt;
`@Column`(nullable = false)
`@Enumerated`(EnumType.STRING)
private StatusRefreshToken status = StatusRefreshToken.ACTIVE;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/RefreshTokenEntity.java`
at line 29, Add database indexes to the RefreshTokenEntity JPA mapping to
support queries like updateStatusByUserAndCurrentStatus(...) and cleanup by
expiration: update the `@Table`(...) annotation on class RefreshTokenEntity to
include `@Index` entries for the user+status lookup and for expires_at (e.g. index
on columnList "user_id, status" and index on "expires_at"). Ensure index names
are unique (e.g. idx_refresh_token_user_status, idx_refresh_token_expires_at)
and that the column names match the entity mapping for the user relationship and
the expiresAt field.

@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;
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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}.
*
* <p><b>Persistence exceptions:</b> 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 {
Comment on lines +29 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider replacing @AllArgsConstructor with @Builder or a custom constructor.

The @AllArgsConstructor annotation generates a constructor that accepts id, createdAt, and updatedAt, which are auto-generated fields. Callers could inadvertently set these, potentially causing conflicts with JPA's lifecycle management. A @Builder pattern or a custom constructor with only user-settable fields would be safer for entity creation.

♻️ Proposed alternative using `@Builder`
 `@Setter`
 `@Getter`
 `@NoArgsConstructor`
-@AllArgsConstructor
+@Builder
 `@Table`(name = "users")
 `@Entity`
 `@EqualsAndHashCode`(onlyExplicitlyIncluded = true)
 public class UserEntity {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java`
around lines 29 - 36, The entity UserEntity currently uses Lombok's
`@AllArgsConstructor` which exposes auto-managed fields (id, createdAt,
updatedAt); replace `@AllArgsConstructor` with a safer pattern (e.g., Lombok's
`@Builder` or a custom constructor) so callers can only set user-controlled
fields: remove `@AllArgsConstructor` from the class declaration, add `@Builder` (or
add a constructor that accepts only username/email/password/etc.), and ensure
id, createdAt, and updatedAt remain managed by JPA and are not present in the
public constructor or builder API.


/** 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.smartjam.smartjamapi.enums;

/**
* Represents the lifecycle status of a refresh token stored in the database.
*
* <p>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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.smartjam.smartjamapi.enums;

/**
* Represents the role of a user within the SmartJam platform.
*
* <p>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
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<RefreshTokenEntity, UUID> {

/**
* Finds a refresh token record by the hash of the raw token string.
*
* <p>
*
* @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}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix Javadoc parameter name in @throws clause.

The method parameter is tokenHash, but Javadoc says token.

✏️ Proposed fix
-     * `@throws` IllegalArgumentException if {`@code` token} is {`@code` null}
+     * `@throws` IllegalArgumentException if {`@code` tokenHash} is {`@code` null}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/RefreshTokenRepository.java`
at line 36, Update the Javadoc in RefreshTokenRepository for the method that
accepts tokenHash: change the `@throws` clause to reference the actual parameter
name tokenHash (i.e., "@throws IllegalArgumentException if {`@code` tokenHash} is
{`@code` null}") so the documentation matches the method signature.

* @throws DataAccessException if a database access error occurs (e.g. connection failure, query execution error)
*/
Optional<RefreshTokenEntity> findByTokenHash(String tokenHash);

/**
* Updates the status of the refresh token identified by the given token hash.
*
* <p>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.
*
* <p>
*
* @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}.
*
* <p>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}.
*
* <p>
*
* @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);
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<UserEntity, UUID> {

/**
* Finds a user by their email address.
*
* <p>
*
* @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<UserEntity> findByEmail(String email);

/**
* Checks whether a user with the given email address already exists.
*
* <p>Prefer this method over {@link #findByEmail(String)} when only presence needs to be verified, as it avoids
* loading the full entity.
*
* <p>
*
* @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);
}