From 44f7aad00717e4915513cda78696b3fb838188bb Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 30 Nov 2025 23:53:23 +0100 Subject: [PATCH] feat: add terms acceptance timestamp tracking and migration script --- api/server/controllers/UserController.js | 14 +++- client/src/data-provider/mutations.ts | 1 + config/list-users.js | 9 +- config/migrate-terms-timestamp.js | 100 +++++++++++++++++++++++ config/reset-terms.js | 5 +- package.json | 1 + packages/api/src/utils/env.ts | 1 + packages/data-provider/src/types.ts | 1 + packages/data-schemas/src/schema/user.ts | 4 + packages/data-schemas/src/types/user.ts | 2 + 10 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 config/migrate-terms-timestamp.js diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index e95cdc36a0..2812ff70b5 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -77,7 +77,10 @@ const getTermsStatusController = async (req, res) => { if (!user) { return res.status(404).json({ message: 'User not found' }); } - res.status(200).json({ termsAccepted: !!user.termsAccepted }); + res.status(200).json({ + termsAccepted: !!user.termsAccepted, + termsAcceptedAt: user.termsAcceptedAt || null, + }); } catch (error) { logger.error('Error fetching terms acceptance status:', error); res.status(500).json({ message: 'Error fetching terms acceptance status' }); @@ -86,7 +89,14 @@ const getTermsStatusController = async (req, res) => { const acceptTermsController = async (req, res) => { try { - const user = await User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true }); + const user = await User.findByIdAndUpdate( + req.user.id, + { + termsAccepted: true, + termsAcceptedAt: new Date(), + }, + { new: true }, + ); if (!user) { return res.status(404).json({ message: 'User not found' }); } diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 7abea71187..64360a6df4 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -1068,6 +1068,7 @@ export const useAcceptTermsMutation = ( onSuccess: (data, variables, context) => { queryClient.setQueryData([QueryKeys.userTerms], { termsAccepted: true, + termsAcceptedAt: new Date().toISOString(), }); options?.onSuccess?.(data, variables, context); }, diff --git a/config/list-users.js b/config/list-users.js index 7315ff7251..1c6f7445c2 100644 --- a/config/list-users.js +++ b/config/list-users.js @@ -7,7 +7,10 @@ const connect = require('./connect'); const listUsers = async () => { try { await connect(); - const users = await User.find({}, 'email provider avatar username name createdAt'); + const users = await User.find( + {}, + 'email provider avatar username name createdAt termsAccepted termsAcceptedAt', + ); console.log('\nUser List:'); console.log('----------------------------------------'); @@ -18,6 +21,10 @@ const listUsers = async () => { console.log(`Name: ${user.name || 'N/A'}`); console.log(`Provider: ${user.provider || 'email'}`); console.log(`Created: ${user.createdAt}`); + console.log(`Terms Accepted: ${user.termsAccepted ? 'Yes' : 'No'}`); + console.log( + `Terms Accepted At: ${user.termsAcceptedAt ? user.termsAcceptedAt.toISOString() : 'N/A'}`, + ); console.log('----------------------------------------'); }); diff --git a/config/migrate-terms-timestamp.js b/config/migrate-terms-timestamp.js new file mode 100644 index 0000000000..19368a87b5 --- /dev/null +++ b/config/migrate-terms-timestamp.js @@ -0,0 +1,100 @@ +const path = require('path'); +const mongoose = require('mongoose'); +const { User } = require('@librechat/data-schemas').createModels(mongoose); +require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); +const { askQuestion, silentExit } = require('./helpers'); +const connect = require('./connect'); + +/** + * Migration script for Terms Acceptance Timestamp Tracking + * + * This script migrates existing users who have termsAccepted: true but no termsAcceptedAt timestamp. + * For these users, it sets termsAcceptedAt to their account creation date (createdAt) as a fallback. + * + * Usage: npm run migrate:terms-timestamp + */ +(async () => { + await connect(); + + console.purple('--------------------------'); + console.purple('Migrate Terms Acceptance Timestamps'); + console.purple('--------------------------'); + + // Count users that need migration + const usersToMigrate = await User.countDocuments({ + termsAccepted: true, + $or: [{ termsAcceptedAt: null }, { termsAcceptedAt: { $exists: false } }], + }); + + if (usersToMigrate === 0) { + console.green( + 'No users need migration. All users with termsAccepted: true already have a termsAcceptedAt timestamp.', + ); + silentExit(0); + } + + console.yellow( + `Found ${usersToMigrate} user(s) with termsAccepted: true but no termsAcceptedAt timestamp.`, + ); + console.yellow( + 'These users will have their termsAcceptedAt set to their account creation date (createdAt).', + ); + + const confirm = await askQuestion('Are you sure you want to proceed? (y/n): '); + + if (confirm.toLowerCase() !== 'y') { + console.yellow('Operation cancelled.'); + silentExit(0); + } + + try { + // Find all users that need migration and update them + const cursor = User.find({ + termsAccepted: true, + $or: [{ termsAcceptedAt: null }, { termsAcceptedAt: { $exists: false } }], + }).cursor(); + + let migratedCount = 0; + let errorCount = 0; + + for await (const user of cursor) { + try { + // Use createdAt as fallback for termsAcceptedAt + const termsAcceptedAt = user.createdAt || new Date(); + await User.updateOne({ _id: user._id }, { $set: { termsAcceptedAt } }); + migratedCount++; + + if (migratedCount % 100 === 0) { + console.yellow(`Migrated ${migratedCount} users...`); + } + } catch (error) { + console.red(`Error migrating user ${user._id}: ${error.message}`); + errorCount++; + } + } + + console.green(`Migration complete!`); + console.green(`Successfully migrated: ${migratedCount} user(s)`); + if (errorCount > 0) { + console.red(`Errors encountered: ${errorCount}`); + } + } catch (error) { + console.red('Error during migration:', error); + silentExit(1); + } + + silentExit(0); +})(); + +process.on('uncaughtException', (err) => { + if (!err.message.includes('fetch failed')) { + console.error('There was an uncaught error:'); + console.error(err); + } + + if (err.message.includes('fetch failed')) { + return; + } else { + process.exit(1); + } +}); diff --git a/config/reset-terms.js b/config/reset-terms.js index 0b3be80661..f6d2ea152d 100644 --- a/config/reset-terms.js +++ b/config/reset-terms.js @@ -21,7 +21,10 @@ const connect = require('./connect'); } try { - const result = await User.updateMany({}, { $set: { termsAccepted: false } }); + const result = await User.updateMany( + {}, + { $set: { termsAccepted: false, termsAcceptedAt: null } }, + ); console.green(`Updated ${result.modifiedCount} user(s).`); } catch (error) { console.red('Error resetting terms acceptance:', error); diff --git a/package.json b/package.json index adc7a7a58f..40c84c69c7 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "b:balance": "bun config/add-balance.js", "b:list-balances": "bun config/list-balances.js", "reset-terms": "node config/reset-terms.js", + "migrate:terms-timestamp": "node config/migrate-terms-timestamp.js", "flush-cache": "node config/flush-cache.js", "migrate:agent-permissions:dry-run": "node config/migrate-agent-permissions.js --dry-run", "migrate:agent-permissions": "node config/migrate-agent-permissions.js", diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 3a22d897e6..a3f9a16962 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -26,6 +26,7 @@ const ALLOWED_USER_FIELDS = [ 'emailVerified', 'twoFactorEnabled', 'termsAccepted', + 'termsAcceptedAt', ] as const; type AllowedUserField = (typeof ALLOWED_USER_FIELDS)[number]; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 997e133be1..4b6a8e4c9a 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -623,6 +623,7 @@ export type TCustomConfigSpeechResponse = { [key: string]: string }; export type TUserTermsResponse = { termsAccepted: boolean; + termsAcceptedAt: Date | string | null; }; export type TAcceptTermsResponse = { diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index c2bdc6fd34..d883cf7466 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -132,6 +132,10 @@ const userSchema = new Schema( type: Boolean, default: false, }, + termsAcceptedAt: { + type: Date, + default: null, + }, personalization: { type: { memories: { diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index a78c4679f2..d9367b7fb6 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -31,6 +31,7 @@ export interface IUser extends Document { }>; expiresAt?: Date; termsAccepted?: boolean; + termsAcceptedAt?: Date | null; personalization?: { memories?: boolean; }; @@ -68,6 +69,7 @@ export interface UpdateUserRequest { plugins?: string[]; twoFactorEnabled?: boolean; termsAccepted?: boolean; + termsAcceptedAt?: Date | null; personalization?: { memories?: boolean; };