feat(modtool): populate lastPurchase / tradeLockExpiry / identityBans

ModToolUserInfoComposer used to send three trailing fields hardcoded
to empty/zero — the client rendered placeholders for every user, on
every panel open:

  appendString("");  // Trading lock expiry timestamp
  appendString("");  // Last Purchase Timestamp
  appendInt(0);      // Number of account bans

These are useful moderation signals and the data already exists in
the live tables. Wire them up.

Last Purchase
  Query MAX(timestamp) FROM logs_shop_purchases WHERE user_id = ?.
  Returns the most recent purchase epoch. Rendered as yyyy-MM-dd HH:mm.
  Empty when the user has never bought anything (the query returns
  NULL → getInt returns 0 → formatUnixTimestamp emits "").

Trading lock expiry
  Query MAX(trade_locked_until) FROM sanctions WHERE habbo_id = ? AND
  trade_locked_until > <now>. Latest ACTIVE lock only — past entries
  don't count. Same yyyy-MM-dd HH:mm format. Empty when no active
  lock.

Identity related bans
  Count of DISTINCT other user accounts that have a ban entry against
  the same machine_id as the target. Self is excluded since the target's
  own bans already show up in banCount. An empty machine_id (default
  '') short-circuits to 0 so we never match accounts whose machine
  fingerprint was never recorded.

The existing totalBans counter is extracted into a helper alongside
the three new ones — cleaner than the inline try-catch tower it used
to live in, same behaviour.

Format choice yyyy-MM-dd HH:mm matches the timestamp shown elsewhere
in moderation UI; both string fields go through the same formatter so
the empty case stays consistent (empty string, not "1970-01-01...").

No client-side changes needed — ModeratorUserInfoData already parses
both strings and the int, and the React ModToolsUserView already
renders them. They were just always empty before.
This commit is contained in:
simoleo89
2026-05-20 21:30:49 +02:00
committed by simoleo89
parent 69d770b65e
commit e7ba4d0926
@@ -12,10 +12,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.sql.*; import java.sql.*;
import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
public class ModToolUserInfoComposer extends MessageComposer { public class ModToolUserInfoComposer extends MessageComposer {
private static final Logger LOGGER = LoggerFactory.getLogger(ModToolUserInfoComposer.class); private static final Logger LOGGER = LoggerFactory.getLogger(ModToolUserInfoComposer.class);
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private final ResultSet set; private final ResultSet set;
private final boolean hideMail; private final boolean hideMail;
@@ -29,37 +32,30 @@ public class ModToolUserInfoComposer extends MessageComposer {
protected ServerMessage composeInternal() { protected ServerMessage composeInternal() {
this.response.init(Outgoing.ModToolUserInfoComposer); this.response.init(Outgoing.ModToolUserInfoComposer);
try { try {
int totalBans = 0; int userId = this.set.getInt("user_id");
String machineId = this.set.getString("machine_id");
int now = Emulator.getIntUnixTimestamp();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); int totalBans = countBansForUser(userId);
PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) AS amount FROM bans WHERE user_id = ?")) { int lastPurchaseTimestamp = fetchLastPurchaseTimestamp(userId);
statement.setInt(1, this.set.getInt("user_id")); int tradeLockExpiryTimestamp = fetchActiveTradeLockExpiry(userId, now);
try (ResultSet set = statement.executeQuery()) { int identityRelatedBanCount = countIdentityRelatedBans(userId, machineId);
if (set.next()) {
totalBans = set.getInt("amount");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
this.response.appendInt(this.set.getInt("user_id")); this.response.appendInt(userId);
this.response.appendString(this.set.getString("username")); this.response.appendString(this.set.getString("username"));
this.response.appendString(this.set.getString("look")); this.response.appendString(this.set.getString("look"));
this.response.appendInt((Emulator.getIntUnixTimestamp() - this.set.getInt("account_created")) / 60); this.response.appendInt((now - this.set.getInt("account_created")) / 60);
this.response.appendInt((this.set.getInt("online") == 1 ? 0 : Emulator.getIntUnixTimestamp() - this.set.getInt("last_online")) / 60); this.response.appendInt((this.set.getInt("online") == 1 ? 0 : now - this.set.getInt("last_online")) / 60);
this.response.appendBoolean(this.set.getInt("online") == 1); this.response.appendBoolean(this.set.getInt("online") == 1);
this.response.appendInt(this.set.getInt("cfh_send")); this.response.appendInt(this.set.getInt("cfh_send"));
this.response.appendInt(this.set.getInt("cfh_abusive")); this.response.appendInt(this.set.getInt("cfh_abusive"));
this.response.appendInt(this.set.getInt("cfh_warnings")); this.response.appendInt(this.set.getInt("cfh_warnings"));
this.response.appendInt(totalBans); // Number of bans this.response.appendInt(totalBans); // Number of bans
this.response.appendInt(this.set.getInt("tradelock_amount")); this.response.appendInt(this.set.getInt("tradelock_amount"));
this.response.appendString(""); //Trading lock expiry timestamp this.response.appendString(formatUnixTimestamp(tradeLockExpiryTimestamp)); // Trading lock expiry timestamp
this.response.appendString(""); //Last Purchase Timestamp this.response.appendString(formatUnixTimestamp(lastPurchaseTimestamp)); // Last Purchase Timestamp
this.response.appendInt(this.set.getInt("user_id")); //Personal Identification # this.response.appendInt(userId); //Personal Identification #
this.response.appendInt(0); // Number of account bans this.response.appendInt(identityRelatedBanCount); // Number of account bans on the same machine_id
this.response.appendString(this.hideMail ? "" : this.set.getString("mail")); this.response.appendString(this.hideMail ? "" : this.set.getString("mail"));
this.response.appendString("Rank (" + this.set.getInt("rank_id") + "): " + this.set.getString("rank_name")); //user_class_txt this.response.appendString("Rank (" + this.set.getInt("rank_id") + "): " + this.set.getString("rank_name")); //user_class_txt
@@ -90,4 +86,87 @@ public class ModToolUserInfoComposer extends MessageComposer {
public ResultSet getSet() { public ResultSet getSet() {
return set; return set;
} }
private static int countBansForUser(int userId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) AS amount FROM bans WHERE user_id = ?")) {
statement.setInt(1, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("amount");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Most recent purchase timestamp from logs_shop_purchases for this
* user. Returns 0 when the user has never bought anything (in which
* case the wire field stays empty and the client shows the empty
* placeholder).
*/
private static int fetchLastPurchaseTimestamp(int userId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT MAX(`timestamp`) AS ts FROM logs_shop_purchases WHERE user_id = ?")) {
statement.setInt(1, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("ts");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Latest active trade-lock expiry from the sanctions table. Only
* locks expiring in the future are considered — past entries don't
* count. Returns 0 when no active lock exists.
*/
private static int fetchActiveTradeLockExpiry(int userId, int now) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT MAX(trade_locked_until) AS expiry FROM sanctions WHERE habbo_id = ? AND trade_locked_until > ?")) {
statement.setInt(1, userId);
statement.setInt(2, now);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("expiry");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Count of OTHER user accounts that have been banned from the same
* machine_id as this user. An empty machine_id (default '') is
* ignored — never matches anything by definition. Self is excluded
* because the user's own bans are already counted under banCount.
*/
private static int countIdentityRelatedBans(int userId, String machineId) {
if (machineId == null || machineId.isEmpty()) return 0;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COUNT(DISTINCT user_id) AS amount FROM bans WHERE machine_id = ? AND user_id != ?")) {
statement.setString(1, machineId);
statement.setInt(2, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("amount");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Wire format for date fields is `yyyy-MM-dd HH:mm`. A 0 timestamp
* is rendered as an empty string so the client falls back to its
* empty-state placeholder.
*/
private static String formatUnixTimestamp(int timestamp) {
if (timestamp <= 0) return "";
return DATE_FORMAT.format(new Date(timestamp * 1000L));
}
} }