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