From 019979791a3db69b2904e1ac844aa6c25b69661f Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Sat, 26 Sep 2026 03:47:48 -0400 Subject: [PATCH 1/2] TAN-987: Update User and Session table to add createdAt (allow nulls for existing rows) --- db/README.md | 89 +++++++++++-------- ...edAt_column_to_User_and_Session_tables.SQL | 19 ++++ .../codebloom/common/db/models/Session.java | 6 ++ .../codebloom/common/db/models/user/User.java | 5 ++ .../repos/session/SessionSqlRepository.java | 18 ++-- .../db/repos/user/UserSqlRepository.java | 36 +++++--- 6 files changed, 118 insertions(+), 55 deletions(-) create mode 100644 db/migration/V0079__Add_createdAt_column_to_User_and_Session_tables.SQL diff --git a/db/README.md b/db/README.md index a42c0082e..572ac6836 100644 --- a/db/README.md +++ b/db/README.md @@ -4,11 +4,12 @@ erDiagram Achievement { timestamp_with_time_zone createdAt "{NOT_NULL}" - timestamp_with_time_zone deletedAt - text description + timestamp_with_time_zone deletedAt + text description uuid id PK "{NOT_NULL}" - boolean isActive + boolean isActive Tag leaderboard "" + uuid leaderboardId FK "{NOT_NULL}" AchievementPlaceEnum place " {NOT_NULL}" character_varying title "{NOT_NULL}" uuid userId FK "{NOT_NULL}" @@ -19,7 +20,7 @@ erDiagram timestamp_with_time_zone expiresAt "{NOT_NULL}" uuid id PK "{NOT_NULL}" text message "{NOT_NULL}" - boolean showTimer + boolean showTimer } ApiKey { @@ -39,7 +40,7 @@ erDiagram Auth { timestamp_with_time_zone createdAt "{NOT_NULL}" - text csrf + text csrf uuid id PK "{NOT_NULL}" text token "{NOT_NULL}" } @@ -47,23 +48,23 @@ erDiagram BackgroundTask { timestamp_with_time_zone completedAt "{NOT_NULL}" uuid id PK "{NOT_NULL}" - BackgroundTaskEnum task " {NOT_NULL}" + BackgroundTaskEnum task " {NOT_NULL}" } Club { - text description + text description uuid id PK "{NOT_NULL}" character_varying name "{NOT_NULL}" character_varying password "{NOT_NULL}" character_varying slug UK "{NOT_NULL}" - character_varying splashIconUrl + character_varying splashIconUrl Tag tag " {NOT_NULL}" } DiscordClub { timestamp_with_time_zone createdAt "{NOT_NULL}" - timestamp_with_time_zone deletedAt - text description + timestamp_with_time_zone deletedAt + text description uuid id PK "{NOT_NULL}" text name "{NOT_NULL}" Tag tag " {NOT_NULL}" @@ -71,40 +72,40 @@ erDiagram DiscordClubMetadata { uuid discordClubId FK "{NOT_NULL}" - text guildId + text guildId uuid id PK "{NOT_NULL}" - text leaderboardChannelId + text leaderboardChannelId } Job { integer attempts "{NOT_NULL}" - timestamp_with_time_zone completedAt + timestamp_with_time_zone completedAt timestamp_with_time_zone createdAt "{NOT_NULL}" uuid id PK "{NOT_NULL}" timestamp_with_time_zone nextAttemptAt "{NOT_NULL}" - timestamp_with_time_zone processedAt + timestamp_with_time_zone processedAt character_varying questionId "{NOT_NULL}" JobStatus status " {NOT_NULL}" } Leaderboard { timestamp_with_time_zone createdAt "{NOT_NULL}" - timestamp_with_time_zone deletedAt + timestamp_with_time_zone deletedAt uuid id PK "{NOT_NULL}" character_varying name "{NOT_NULL}" - timestamp_with_time_zone shouldExpireBy - text syntaxHighlightingLanguage + timestamp_with_time_zone shouldExpireBy + text syntaxHighlightingLanguage } Lobby { timestamp_with_time_zone createdAt "{NOT_NULL}" - timestamp_with_time_zone expiresAt + timestamp_with_time_zone expiresAt uuid id PK "{NOT_NULL}" text joinCode "{NOT_NULL}" integer playerCount "{NOT_NULL}" LobbyStatusEnum status " {NOT_NULL}" boolean tie "{NOT_NULL}" - uuid winnerId FK + uuid winnerId FK } LobbyPlayer { @@ -117,8 +118,8 @@ erDiagram LobbyPlayerQuestion { uuid id PK "{NOT_NULL}" uuid lobbyPlayerId FK,UK "{NOT_NULL}" - integer points - uuid questionId FK,UK + integer points + uuid questionId FK,UK } LobbyQuestion { @@ -147,20 +148,20 @@ erDiagram Question { double_precision acceptanceRate "{NOT_NULL}" - text code + text code timestamp_with_time_zone createdAt "{NOT_NULL}" - text description + text description uuid id PK "{NOT_NULL}" - text language - character_varying memory - integer pointsAwarded + text language + character_varying memory + integer pointsAwarded question_difficulty questionDifficulty " {NOT_NULL}" text questionLink "{NOT_NULL}" smallint questionNumber "{NOT_NULL}" character_varying questionSlug "{NOT_NULL}" text questionTitle "{NOT_NULL}" - character_varying runtime - text submissionId UK + character_varying runtime + text submissionId UK timestamp_with_time_zone submittedAt "{NOT_NULL}" uuid userId FK "{NOT_NULL}" } @@ -168,7 +169,7 @@ erDiagram QuestionBank { real acceptanceRate "{NOT_NULL}" timestamp_with_time_zone createdAt "{NOT_NULL}" - text description + text description uuid id PK "{NOT_NULL}" question_difficulty questionDifficulty " {NOT_NULL}" text questionLink "{NOT_NULL}" @@ -189,7 +190,7 @@ erDiagram Report { timestamp_with_time_zone createdAt "{NOT_NULL}" text description "{NOT_NULL}" - character_varying email + character_varying email uuid id PK "{NOT_NULL}" character_varying title "{NOT_NULL}" } @@ -205,11 +206,19 @@ erDiagram character_varying discordId UK "{NOT_NULL}" character_varying discordName "{NOT_NULL}" uuid id PK "{NOT_NULL}" - character_varying leetcodeUsername UK - character_varying nickname - character_varying profileUrl - character_varying schoolEmail - text verifyKey + character_varying leetcodeUsername UK + character_varying nickname + character_varying profileUrl + character_varying schoolEmail + text verifyKey + } + + UserMetrics { + timestamp_with_time_zone createdAt "{NOT_NULL}" + timestamp_with_time_zone deletedAt + uuid id PK "{NOT_NULL}" + integer points "{NOT_NULL}" + uuid userId FK "{NOT_NULL}" } UserTag { @@ -225,7 +234,7 @@ erDiagram } flyway_schema_history { - integer checksum + integer checksum character_varying description "{NOT_NULL}" integer execution_time "{NOT_NULL}" character_varying installed_by "{NOT_NULL}" @@ -234,9 +243,10 @@ erDiagram character_varying script "{NOT_NULL}" boolean success "{NOT_NULL}" character_varying type "{NOT_NULL}" - character_varying version + character_varying version } + Achievement }o--|| Leaderboard : "leaderboardId" Achievement }o--|| User : "userId" ApiKey }o--|| User : "updatedBy" ApiKeyAccess }o--|| ApiKey : "apiKeyId" @@ -254,6 +264,7 @@ erDiagram QuestionTopic }o--|| Question : "questionId" QuestionTopic }o--|| QuestionBank : "questionBankId" Session }o--|| User : "userId" + UserMetrics }o--|| User : "userId" UserTag }o--|| User : "userId" ``` @@ -264,12 +275,12 @@ erDiagram

- Also view in mermaid.live + Also view in mermaid.live

- Last updated: 02/15/2026 + Last updated: 09/26/2026

diff --git a/db/migration/V0079__Add_createdAt_column_to_User_and_Session_tables.SQL b/db/migration/V0079__Add_createdAt_column_to_User_and_Session_tables.SQL new file mode 100644 index 000000000..0a1f6e662 --- /dev/null +++ b/db/migration/V0079__Add_createdAt_column_to_User_and_Session_tables.SQL @@ -0,0 +1,19 @@ +-- check only applies to new rows not being null, not old rows + +ALTER TABLE IF EXISTS "User" +ADD COLUMN "createdAt" TIMESTAMP WITH TIME ZONE; + +ALTER TABLE IF EXISTS "User" +ALTER COLUMN "createdAt" SET DEFAULT NOW(); + +ALTER TABLE IF EXISTS "User" +ADD CONSTRAINT "ck_user_createdAt_not_null" CHECK ("createdAt" IS NOT NULL) NOT VALID; + +ALTER TABLE IF EXISTS "Session" +ADD COLUMN "createdAt" TIMESTAMP WITH TIME ZONE; + +ALTER TABLE IF EXISTS "Session" +ALTER COLUMN "createdAt" SET DEFAULT NOW(); + +ALTER TABLE IF EXISTS "Session" +ADD CONSTRAINT "ck_session_createdAt_not_null" CHECK ("createdAt" IS NOT NULL) NOT VALID; diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/models/Session.java b/src/main/java/org/patinanetwork/codebloom/common/db/models/Session.java index b829cc3e9..cbc2969a7 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/models/Session.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/models/Session.java @@ -1,6 +1,7 @@ package org.patinanetwork.codebloom.common.db.models; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.Optional; import lombok.Builder; import lombok.EqualsAndHashCode; @@ -8,6 +9,7 @@ import lombok.Setter; import lombok.ToString; import org.patinanetwork.codebloom.common.db.helper.annotations.NotNullColumn; +import org.patinanetwork.codebloom.common.db.helper.annotations.NullColumn; @Getter @Setter @@ -25,6 +27,10 @@ public class Session { @NotNullColumn private LocalDateTime expiresAt; + /** can be null if it's an old entry */ + @NullColumn + private OffsetDateTime createdAt; + // public Session(final String userId, final LocalDateTime expiresAt) { // this.userId = userId; // this.expiresAt = expiresAt; diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/models/user/User.java b/src/main/java/org/patinanetwork/codebloom/common/db/models/user/User.java index 6acc54f19..e17f64335 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/models/user/User.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/models/user/User.java @@ -1,5 +1,6 @@ package org.patinanetwork.codebloom.common.db.models.user; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import lombok.Builder; @@ -50,6 +51,10 @@ public class User { @NullColumn private String schoolEmail; + /** can be null if it's an old entry */ + @NullColumn + private OffsetDateTime createdAt; + /** * If you want to update tags in the database, you have to use the * {@link org.patinanetwork.codebloom.common.db.repos.usertag.UserTagRepository} diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/repos/session/SessionSqlRepository.java b/src/main/java/org/patinanetwork/codebloom/common/db/repos/session/SessionSqlRepository.java index 9da5a42d2..724101be1 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/repos/session/SessionSqlRepository.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/repos/session/SessionSqlRepository.java @@ -1,9 +1,12 @@ package org.patinanetwork.codebloom.common.db.repos.session; +import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.Map; import java.util.Optional; import java.util.UUID; import org.patinanetwork.codebloom.common.db.models.Session; +import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Component; @@ -15,6 +18,7 @@ public class SessionSqlRepository implements SessionRepository { .id(Optional.of(rs.getString("id"))) .userId(rs.getString("userId")) .expiresAt(rs.getTimestamp("expiresAt").toLocalDateTime()) + .createdAt(StandardizedOffsetDateTime.normalize(rs.getObject("createdAt", OffsetDateTime.class))) .build(); private JdbcClient jdbcClient; @@ -25,7 +29,8 @@ public SessionSqlRepository(final JdbcClient jdbcClient) { @Override public void createSession(final Session session) { - String sql = "INSERT INTO \"Session\" (id, \"userId\", \"expiresAt\") VALUES (?, ?, ?) RETURNING \"id\""; + String sql = + "INSERT INTO \"Session\" (id, \"userId\", \"expiresAt\") VALUES (?, ?, ?) RETURNING \"id\", \"createdAt\""; // Don't want dashes inside of the cookie, so better to just remove it from the // ID altogether. session.setId(Optional.of(UUID.randomUUID().toString().replace("-", ""))); @@ -39,21 +44,24 @@ public void createSession(final Session session) { () -> new IllegalStateException("Session ID must be present for insertion."))) .param(2, UUID.fromString(session.getUserId())) .param(3, session.getExpiresAt()) - .query((rs, rowNum) -> rs.getString("id")) + .query((rs, rowNum) -> Map.entry(rs.getString("id"), rs.getObject("createdAt", OffsetDateTime.class))) .optional() - .ifPresent(id -> session.setId(Optional.of(id))); + .ifPresent(entry -> { + session.setId(Optional.of(entry.getKey())); + session.setCreatedAt(StandardizedOffsetDateTime.normalize(entry.getValue())); + }); } @Override public Optional getSessionById(final String id) { - String sql = "SELECT id, \"userId\", \"expiresAt\" FROM \"Session\" WHERE id=?"; + String sql = "SELECT id, \"userId\", \"expiresAt\", \"createdAt\" FROM \"Session\" WHERE id=?"; return jdbcClient.sql(sql).param(1, id).query(SESSION_ROW_MAPPER).optional(); } @Override public ArrayList getSessionsByUserId(final String id) { - String sql = "SELECT id, \"userId\", \"expiresAt\" FROM \"Session\" WHERE \"userId\"=?"; + String sql = "SELECT id, \"userId\", \"expiresAt\", \"createdAt\" FROM \"Session\" WHERE \"userId\"=?"; return new ArrayList<>(jdbcClient .sql(sql) diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/repos/user/UserSqlRepository.java b/src/main/java/org/patinanetwork/codebloom/common/db/repos/user/UserSqlRepository.java index a85c3937b..49569447b 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/repos/user/UserSqlRepository.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/repos/user/UserSqlRepository.java @@ -1,12 +1,15 @@ package org.patinanetwork.codebloom.common.db.repos.user; +import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.Map; import java.util.UUID; import org.patinanetwork.codebloom.common.db.models.user.User; import org.patinanetwork.codebloom.common.db.models.user.UserWithScore; import org.patinanetwork.codebloom.common.db.repos.achievements.AchievementRepository; import org.patinanetwork.codebloom.common.db.repos.user.options.UserFilterOptions; import org.patinanetwork.codebloom.common.db.repos.usertag.UserTagRepository; +import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Component; @@ -41,6 +44,7 @@ public UserSqlRepository( .admin(rs.getBoolean("admin")) .schoolEmail(rs.getString("schoolEmail")) .profileUrl(rs.getString("profileUrl")) + .createdAt(StandardizedOffsetDateTime.normalize(rs.getObject("createdAt", OffsetDateTime.class))) .tags(this.userTagRepository.findTagsByUserId(id)) .achievements(this.achievementRepository.getAchievementsByUserId(id)) .build(); @@ -58,6 +62,7 @@ public UserSqlRepository( .admin(rs.getBoolean("admin")) .schoolEmail(rs.getString("schoolEmail")) .profileUrl(rs.getString("profileUrl")) + .createdAt(StandardizedOffsetDateTime.normalize(rs.getObject("createdAt", OffsetDateTime.class))) .tags(this.userTagRepository.findTagsByUserId(id)) .achievements(this.achievementRepository.getAchievementsByUserId(id)) .totalScore(rs.getInt("totalScore")) @@ -77,11 +82,11 @@ public void createUser(final User user) { VALUES (:id, :discordName, :discordId, :leetcodeUsername, :nickname, :schoolEmail, :admin , :profileUrl) RETURNING - "verifyKey" + "verifyKey", "createdAt" """; user.setId(UUID.randomUUID().toString()); - String verifyKey = jdbcClient + jdbcClient .sql(sql) .param("id", UUID.fromString(user.getId())) .param("discordName", user.getDiscordName()) @@ -91,11 +96,13 @@ public void createUser(final User user) { .param("schoolEmail", user.getSchoolEmail()) .param("admin", user.isAdmin()) .param("profileUrl", user.getProfileUrl()) - .query((rs, rowNum) -> rs.getString("verifyKey")) + .query((rs, rowNum) -> + Map.entry(rs.getString("verifyKey"), rs.getObject("createdAt", OffsetDateTime.class))) .optional() - .orElse(null); - - user.setVerifyKey(verifyKey); + .ifPresent(entry -> { + user.setVerifyKey(entry.getKey()); + user.setCreatedAt(StandardizedOffsetDateTime.normalize(entry.getValue())); + }); } @Override @@ -110,7 +117,8 @@ public User getUserById(final String inputId) { "schoolEmail", admin, "verifyKey", - "profileUrl" + "profileUrl", + "createdAt" FROM "User" WHERE id=:id @@ -136,7 +144,8 @@ public User getUserByLeetcodeUsername(final String inputLeetcodeUsername) { "schoolEmail", admin, "verifyKey", - "profileUrl" + "profileUrl", + "createdAt" FROM "User" WHERE "leetcodeUsername" = :leetcodeUsername """; @@ -161,7 +170,8 @@ public User getUserByDiscordId(final String inputDiscordId) { "schoolEmail", admin, "verifyKey", - "profileUrl" + "profileUrl", + "createdAt" FROM "User" WHERE "discordId" = :discordId @@ -228,7 +238,8 @@ public ArrayList getAllUsers() { "schoolEmail", admin, "verifyKey", - "profileUrl" + "profileUrl", + "createdAt" FROM "User" """; @@ -247,7 +258,8 @@ public ArrayList getAllUsers(final int page, final int pageSize, final Str "schoolEmail", admin, "verifyKey", - "profileUrl" + "profileUrl", + "createdAt" FROM "User" WHERE @@ -298,6 +310,7 @@ public UserWithScore getUserWithScoreByIdAndLeaderboardId( u.admin, u."verifyKey", u."profileUrl", + u."createdAt", m."totalScore" FROM "User" u @@ -329,6 +342,7 @@ public UserWithScore getUserWithScoreByLeetcodeUsernameAndLeaderboardId( u.nickname, u.admin, u."profileUrl", + u."createdAt", u."verifyKey", m."totalScore" FROM From f0a957aa53b839f67ab3bc0fa9c5a4d5b0fb93e0 Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Sat, 26 Sep 2026 04:16:31 -0400 Subject: [PATCH 2/2] TAN-987: add a full class assert for session repo test to ensure all fields correctly align --- .../codebloom/common/db/repos/session/SessionRepositoryTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/org/patinanetwork/codebloom/common/db/repos/session/SessionRepositoryTest.java b/src/test/java/org/patinanetwork/codebloom/common/db/repos/session/SessionRepositoryTest.java index f3c8ed8e3..17c39e6e0 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/db/repos/session/SessionRepositoryTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/db/repos/session/SessionRepositoryTest.java @@ -66,6 +66,7 @@ void testGetSessionById() { Session found = sessionRepository.getSessionById(sessionId).get(); assertNotNull(found); assertEquals(testSession.getId().get(), found.getId().get()); + assertEquals(testSession, found); } @Test