diff --git a/packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js b/packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js new file mode 100644 index 00000000000..a1f89d9d35c --- /dev/null +++ b/packages/identity-service/sequelize/migrations/20260526000000-add-last-active-at.js @@ -0,0 +1,33 @@ +'use strict' + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn( + 'Users', + 'lastActiveAt', + { + type: Sequelize.DATE, + allowNull: true + }, + { transaction } + ) + + await queryInterface.addIndex('Users', ['lastActiveAt'], { + transaction, + name: 'idx_users_lastActiveAt' + }) + }) + }, + + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex('Users', 'idx_users_lastActiveAt', { + transaction + }) + await queryInterface.removeColumn('Users', 'lastActiveAt', { + transaction + }) + }) + } +} diff --git a/packages/identity-service/src/models/user.js b/packages/identity-service/src/models/user.js index 0f646f3cd2d..10cff697229 100644 --- a/packages/identity-service/src/models/user.js +++ b/packages/identity-service/src/models/user.js @@ -69,6 +69,14 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.DATE, allowNull: false }, + // Touched on every app open via the authenticated startup ping + // (GET /user/email). Used for inactivity detection — finer-grained + // and lazier than lastSeenDate, which only moves at user creation + // and on relay. + lastActiveAt: { + type: DataTypes.DATE, + allowNull: true + }, isGuest: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/packages/identity-service/src/routes/idSignals.js b/packages/identity-service/src/routes/idSignals.js index a0adca05bfe..a901c9e85cf 100644 --- a/packages/identity-service/src/routes/idSignals.js +++ b/packages/identity-service/src/routes/idSignals.js @@ -77,7 +77,18 @@ module.exports = function (app) { '/record_ip', authMiddleware, handleResponse(async (req) => { - const { blockchainUserId, handle } = req.user + const { id: userRowId, blockchainUserId, handle } = req.user + + // Fired by the client's recordIPIfNotRecent saga on app open + // (throttled to once per 24h per device), so this is also our + // signal that the user is active. Fire-and-forget — never block + // the IP-record response on this side effect. + models.User.update( + { lastActiveAt: new Date() }, + { where: { id: userRowId } } + ).catch((err) => { + req.logger.error({ err }, 'Failed to update lastActiveAt') + }) try { const userIP = getIP(req) diff --git a/packages/identity-service/src/routes/user.js b/packages/identity-service/src/routes/user.js index 746e38ea8e0..b33efc897bc 100644 --- a/packages/identity-service/src/routes/user.js +++ b/packages/identity-service/src/routes/user.js @@ -65,11 +65,13 @@ module.exports = function (app) { try { const isDeliverable = await isEmailDeliverable(email, req.logger) + const now = new Date() await models.User.create({ email, // Store non checksummed wallet address walletAddress: body.walletAddress.toLowerCase(), - lastSeenDate: Date.now(), + lastSeenDate: now, + lastActiveAt: now, IP, isEmailDeliverable: isDeliverable, isGuest: body.isGuest