Skip to content

Commit 09aae08

Browse files
committed
feat(backend): added latest_login_at field in users
1 parent fe34c40 commit 09aae08

6 files changed

Lines changed: 52 additions & 6 deletions

File tree

backend/database/schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ CREATE TABLE IF NOT EXISTS users (
8787
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
8888
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
8989
logto_synced_at TIMESTAMP WITH TIME ZONE,
90+
latest_login_at TIMESTAMP WITH TIME ZONE,
9091
active BOOLEAN DEFAULT TRUE
9192
);
9293

@@ -98,6 +99,7 @@ CREATE INDEX IF NOT EXISTS idx_users_organization_id ON users(organization_id);
9899
CREATE INDEX IF NOT EXISTS idx_users_active ON users(active);
99100
CREATE INDEX IF NOT EXISTS idx_users_logto_synced ON users(logto_synced_at);
100101
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC);
102+
CREATE INDEX IF NOT EXISTS idx_users_latest_login_at ON users(latest_login_at DESC);
101103

102104
-- Systems table - updated to reference customers table
103105
CREATE TABLE IF NOT EXISTS systems (

backend/entities/local_users.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (r *LocalUserRepository) Create(req *models.CreateLocalUserRequest) (*model
9393
func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) {
9494
query := `
9595
SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data,
96-
u.created_at, u.updated_at, u.logto_synced_at, u.active,
96+
u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.active,
9797
COALESCE(d.name, r.name, c.name) as organization_name,
9898
COALESCE(d.id, r.id, c.id) as organization_local_id
9999
FROM users u
@@ -110,7 +110,7 @@ func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) {
110110
err := r.db.QueryRow(query, id).Scan(
111111
&user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name, &user.Phone,
112112
&user.OrganizationID, &userRoleIDsJSON, &customDataJSON,
113-
&user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.Active,
113+
&user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.Active,
114114
&user.OrganizationName, &user.OrganizationLocalID,
115115
)
116116

@@ -149,7 +149,7 @@ func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) {
149149
func (r *LocalUserRepository) GetByLogtoID(logtoID string) (*models.LocalUser, error) {
150150
query := `
151151
SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data,
152-
u.created_at, u.updated_at, u.logto_synced_at, u.active,
152+
u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.active,
153153
COALESCE(d.name, r.name, c.name) as organization_name,
154154
COALESCE(d.id, r.id, c.id) as organization_local_id
155155
FROM users u
@@ -166,7 +166,7 @@ func (r *LocalUserRepository) GetByLogtoID(logtoID string) (*models.LocalUser, e
166166
err := r.db.QueryRow(query, logtoID).Scan(
167167
&user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name, &user.Phone,
168168
&user.OrganizationID, &userRoleIDsJSON, &customDataJSON,
169-
&user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.Active,
169+
&user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.Active,
170170
&user.OrganizationName, &user.OrganizationLocalID,
171171
)
172172

@@ -292,6 +292,28 @@ func (r *LocalUserRepository) Delete(id string) error {
292292
return nil
293293
}
294294

295+
// UpdateLatestLogin updates the latest_login_at field for a user
296+
func (r *LocalUserRepository) UpdateLatestLogin(userID string) error {
297+
query := `UPDATE users SET latest_login_at = $2, updated_at = $2 WHERE id = $1`
298+
299+
now := time.Now()
300+
result, err := r.db.Exec(query, userID, now)
301+
if err != nil {
302+
return fmt.Errorf("failed to update latest login: %w", err)
303+
}
304+
305+
rowsAffected, err := result.RowsAffected()
306+
if err != nil {
307+
return fmt.Errorf("failed to get rows affected: %w", err)
308+
}
309+
310+
if rowsAffected == 0 {
311+
return fmt.Errorf("user not found")
312+
}
313+
314+
return nil
315+
}
316+
295317
// List returns paginated list of users based on hierarchical RBAC (matches other repository patterns)
296318
func (r *LocalUserRepository) List(userOrgRole, userOrgID, excludeUserID string, page, pageSize int) ([]*models.LocalUser, int, error) {
297319
// Get all organization IDs the user can access hierarchically
@@ -343,7 +365,7 @@ func (r *LocalUserRepository) ListByOrganizations(allowedOrgIDs []string, exclud
343365

344366
query := fmt.Sprintf(`
345367
SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data,
346-
u.created_at, u.updated_at, u.logto_synced_at, u.active,
368+
u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.active,
347369
COALESCE(d.name, r.name, c.name) as organization_name,
348370
COALESCE(d.id, r.id, c.id) as organization_local_id
349371
FROM users u
@@ -369,7 +391,7 @@ func (r *LocalUserRepository) ListByOrganizations(allowedOrgIDs []string, exclud
369391
err := rows.Scan(
370392
&user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name,
371393
&user.Phone, &user.OrganizationID, &userRoleIDsJSON, &customDataJSON,
372-
&user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.Active,
394+
&user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.Active,
373395
&user.OrganizationName, &user.OrganizationLocalID,
374396
)
375397
if err != nil {

backend/methods/auth.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ func ExchangeToken(c *gin.Context) {
8787
ID: localUser.ID, // Local database ID as primary ID
8888
LogtoID: localUser.LogtoID, // Logto ID for reference
8989
}
90+
91+
// Update latest login timestamp
92+
if updateErr := userService.UpdateLatestLogin(localUser.ID); updateErr != nil {
93+
logger.RequestLogger(c, "auth").Warn().
94+
Err(updateErr).
95+
Str("operation", "update_latest_login").
96+
Str("user_id", localUser.ID).
97+
Msg("Failed to update latest login timestamp")
98+
// Don't fail the request if this update fails
99+
}
90100
} else {
91101
// User not in local DB, create temporary user with empty local ID
92102
user = models.User{

backend/models/local_entities.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type LocalUser struct {
8282
CreatedAt time.Time `json:"created_at" db:"created_at"`
8383
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
8484
LogtoSyncedAt *time.Time `json:"logto_synced_at" db:"logto_synced_at"`
85+
LatestLoginAt *time.Time `json:"latest_login_at" db:"latest_login_at"`
8586
Active bool `json:"active" db:"active"`
8687

8788
// Internal fields for database operations (not serialized to JSON)

backend/openapi.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,12 @@ components:
406406
nullable: true
407407
description: Last Logto synchronization timestamp
408408
example: "2025-06-21T10:45:00Z"
409+
latest_login_at:
410+
type: string
411+
format: date-time
412+
nullable: true
413+
description: Timestamp of the last successful login via /auth/exchange endpoint. NULL means user has never logged in.
414+
example: "2025-06-21T15:30:45Z"
409415
active:
410416
type: boolean
411417
description: Whether the user is active

backend/services/local/users.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,11 @@ func (s *LocalUserService) GetUserByLogtoID(logtoID string) (*models.LocalUser,
383383
return s.userRepo.GetByLogtoID(logtoID)
384384
}
385385

386+
// UpdateLatestLogin updates the latest_login_at timestamp for a user
387+
func (s *LocalUserService) UpdateLatestLogin(userID string) error {
388+
return s.userRepo.UpdateLatestLogin(userID)
389+
}
390+
386391
// ListUsers returns paginated users based on hierarchical RBAC (excluding specified user)
387392
func (s *LocalUserService) ListUsers(userOrgRole, userOrgID, excludeUserID string, page, pageSize int) ([]*models.LocalUser, int, error) {
388393
return s.userRepo.List(userOrgRole, userOrgID, excludeUserID, page, pageSize)

0 commit comments

Comments
 (0)