first commit
This commit is contained in:
213
models/Item.js
Normal file
213
models/Item.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const itemSchema = new mongoose.Schema(
|
||||
{
|
||||
// Basic Item Information
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Game Information
|
||||
game: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ["cs2", "rust"],
|
||||
},
|
||||
|
||||
// Category
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
"rifles",
|
||||
"pistols",
|
||||
"knives",
|
||||
"gloves",
|
||||
"stickers",
|
||||
"cases",
|
||||
"smgs",
|
||||
"other",
|
||||
],
|
||||
},
|
||||
|
||||
// Rarity
|
||||
rarity: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
"common",
|
||||
"uncommon",
|
||||
"rare",
|
||||
"mythical",
|
||||
"legendary",
|
||||
"ancient",
|
||||
"exceedingly",
|
||||
],
|
||||
},
|
||||
|
||||
// Wear Condition (for CS2 items)
|
||||
wear: {
|
||||
type: String,
|
||||
enum: ["fn", "mw", "ft", "ww", "bs", null],
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Float Value (for CS2 items)
|
||||
float: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Phase (for Doppler, Gamma Doppler, etc.)
|
||||
phase: {
|
||||
type: String,
|
||||
enum: [
|
||||
"Phase 1",
|
||||
"Phase 2",
|
||||
"Phase 3",
|
||||
"Phase 4",
|
||||
"Ruby",
|
||||
"Sapphire",
|
||||
"Black Pearl",
|
||||
"Emerald",
|
||||
null,
|
||||
],
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Special Properties
|
||||
statTrak: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
souvenir: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
// Price (seller's listing price)
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
|
||||
// Market Price (from SteamAPIs.com)
|
||||
marketPrice: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Last price update timestamp
|
||||
priceUpdatedAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Price Override (admin-set custom price)
|
||||
priceOverride: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
// Seller Information
|
||||
seller: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["active", "sold", "removed"],
|
||||
default: "active",
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
listedAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
soldAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Buyer (if sold)
|
||||
buyer: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Featured
|
||||
featured: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
// Views counter
|
||||
views: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Indexes for better query performance
|
||||
itemSchema.index({ game: 1, status: 1 });
|
||||
itemSchema.index({ category: 1, status: 1 });
|
||||
itemSchema.index({ rarity: 1, status: 1 });
|
||||
itemSchema.index({ price: 1, status: 1 });
|
||||
itemSchema.index({ seller: 1, status: 1 });
|
||||
itemSchema.index({ featured: 1, status: 1 });
|
||||
itemSchema.index({ listedAt: -1 });
|
||||
itemSchema.index({ phase: 1 });
|
||||
itemSchema.index({ name: 1 }); // For price updates
|
||||
|
||||
// Virtual for seller details
|
||||
itemSchema.virtual("sellerDetails", {
|
||||
ref: "User",
|
||||
localField: "seller",
|
||||
foreignField: "_id",
|
||||
justOne: true,
|
||||
});
|
||||
|
||||
// Methods
|
||||
itemSchema.methods.markAsSold = function (buyerId) {
|
||||
this.status = "sold";
|
||||
this.soldAt = new Date();
|
||||
this.buyer = buyerId;
|
||||
return this.save();
|
||||
};
|
||||
|
||||
itemSchema.methods.incrementViews = function () {
|
||||
this.views += 1;
|
||||
return this.save();
|
||||
};
|
||||
|
||||
itemSchema.methods.updateMarketPrice = function (newPrice) {
|
||||
this.marketPrice = newPrice;
|
||||
this.priceUpdatedAt = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
const Item = mongoose.model("Item", itemSchema);
|
||||
|
||||
export default Item;
|
||||
180
models/MarketPrice.js
Normal file
180
models/MarketPrice.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
/**
|
||||
* MarketPrice Model
|
||||
* Stores reference prices from Steam market for quick lookups
|
||||
* Used when loading inventory or updating item prices
|
||||
*/
|
||||
|
||||
const marketPriceSchema = new mongoose.Schema(
|
||||
{
|
||||
// Item name (market_name from Steam API)
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Game identifier
|
||||
game: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ["cs2", "rust"],
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Steam App ID
|
||||
appId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Market hash name (unique identifier from Steam)
|
||||
marketHashName: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// Price in USD
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
|
||||
// Type of price used (safe, median, mean, avg, latest)
|
||||
priceType: {
|
||||
type: String,
|
||||
enum: ["safe", "median", "mean", "avg", "latest"],
|
||||
default: "safe",
|
||||
},
|
||||
|
||||
// Item image URL
|
||||
image: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Border color (rarity indicator)
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Steam name ID
|
||||
nameId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Last updated timestamp
|
||||
lastUpdated: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
collection: "marketprices",
|
||||
}
|
||||
);
|
||||
|
||||
// Compound indexes for fast lookups
|
||||
marketPriceSchema.index({ game: 1, name: 1 });
|
||||
marketPriceSchema.index({ game: 1, marketHashName: 1 });
|
||||
marketPriceSchema.index({ game: 1, price: -1 }); // For sorting by price
|
||||
marketPriceSchema.index({ lastUpdated: -1 }); // For finding outdated prices
|
||||
|
||||
// Static method to find price by market hash name
|
||||
marketPriceSchema.statics.findByMarketHashName = async function (
|
||||
marketHashName,
|
||||
game = null
|
||||
) {
|
||||
const query = { marketHashName };
|
||||
if (game) query.game = game;
|
||||
|
||||
return await this.findOne(query);
|
||||
};
|
||||
|
||||
// Static method to find price by name (partial match)
|
||||
marketPriceSchema.statics.findByName = async function (name, game = null) {
|
||||
const query = {
|
||||
$or: [
|
||||
{ name: name },
|
||||
{ name: { $regex: name, $options: "i" } },
|
||||
{ marketHashName: name },
|
||||
{ marketHashName: { $regex: name, $options: "i" } },
|
||||
],
|
||||
};
|
||||
|
||||
if (game) query.game = game;
|
||||
|
||||
return await this.find(query).limit(10);
|
||||
};
|
||||
|
||||
// Static method to get items by game
|
||||
marketPriceSchema.statics.getByGame = async function (game, options = {}) {
|
||||
const { limit = 100, skip = 0, minPrice = 0, maxPrice = null } = options;
|
||||
|
||||
const query = { game };
|
||||
if (minPrice > 0) query.price = { $gte: minPrice };
|
||||
if (maxPrice) {
|
||||
query.price = query.price || {};
|
||||
query.price.$lte = maxPrice;
|
||||
}
|
||||
|
||||
return await this.find(query)
|
||||
.sort({ price: -1 })
|
||||
.limit(limit)
|
||||
.skip(skip);
|
||||
};
|
||||
|
||||
// Static method to get price statistics
|
||||
marketPriceSchema.statics.getStats = async function (game = null) {
|
||||
const match = game ? { game } : {};
|
||||
|
||||
const stats = await this.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
count: { $sum: 1 },
|
||||
avgPrice: { $avg: "$price" },
|
||||
minPrice: { $min: "$price" },
|
||||
maxPrice: { $max: "$price" },
|
||||
totalValue: { $sum: "$price" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return stats[0] || {
|
||||
count: 0,
|
||||
avgPrice: 0,
|
||||
minPrice: 0,
|
||||
maxPrice: 0,
|
||||
totalValue: 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Instance method to check if price is outdated
|
||||
marketPriceSchema.methods.isOutdated = function (hours = 24) {
|
||||
const now = new Date();
|
||||
const diff = now - this.lastUpdated;
|
||||
const hoursDiff = diff / (1000 * 60 * 60);
|
||||
return hoursDiff > hours;
|
||||
};
|
||||
|
||||
// Instance method to update price
|
||||
marketPriceSchema.methods.updatePrice = async function (newPrice, priceType) {
|
||||
this.price = newPrice;
|
||||
if (priceType) this.priceType = priceType;
|
||||
this.lastUpdated = new Date();
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
const MarketPrice = mongoose.model("MarketPrice", marketPriceSchema);
|
||||
|
||||
export default MarketPrice;
|
||||
136
models/Session.js
Normal file
136
models/Session.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const SessionSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
steamId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
refreshToken: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
ip: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
userAgent: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
device: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
browser: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
os: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
location: {
|
||||
country: { type: String, default: null },
|
||||
city: { type: String, default: null },
|
||||
region: { type: String, default: null },
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
lastActivity: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: { expires: 0 }, // TTL index - automatically delete expired sessions
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Index for cleaning up old sessions
|
||||
SessionSchema.index({ createdAt: 1 });
|
||||
SessionSchema.index({ userId: 1, isActive: 1 });
|
||||
|
||||
// Method to mark session as inactive
|
||||
SessionSchema.methods.deactivate = async function () {
|
||||
this.isActive = false;
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Method to update last activity
|
||||
SessionSchema.methods.updateActivity = async function () {
|
||||
this.lastActivity = Date.now();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Static method to clean up inactive sessions for a user
|
||||
SessionSchema.statics.cleanupUserSessions = async function (userId, keepCurrent = null) {
|
||||
const query = {
|
||||
userId,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
if (keepCurrent) {
|
||||
query._id = { $ne: keepCurrent };
|
||||
}
|
||||
|
||||
return this.deleteMany(query);
|
||||
};
|
||||
|
||||
// Static method to get active sessions for a user
|
||||
SessionSchema.statics.getActiveSessions = async function (userId) {
|
||||
return this.find({
|
||||
userId,
|
||||
isActive: true,
|
||||
expiresAt: { $gt: new Date() },
|
||||
}).sort({ lastActivity: -1 });
|
||||
};
|
||||
|
||||
// Static method to revoke all sessions except current
|
||||
SessionSchema.statics.revokeAllExcept = async function (userId, currentSessionId) {
|
||||
return this.updateMany(
|
||||
{
|
||||
userId,
|
||||
_id: { $ne: currentSessionId },
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
$set: { isActive: false },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Static method to revoke all sessions
|
||||
SessionSchema.statics.revokeAll = async function (userId) {
|
||||
return this.updateMany(
|
||||
{
|
||||
userId,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
$set: { isActive: false },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default mongoose.model('Session', SessionSchema);
|
||||
487
models/Trade.js
Normal file
487
models/Trade.js
Normal file
@@ -0,0 +1,487 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
/**
|
||||
* Trade Model
|
||||
* Tracks Steam trade offers and their states
|
||||
*/
|
||||
|
||||
const tradeSchema = new mongoose.Schema(
|
||||
{
|
||||
// Trade offer ID from Steam
|
||||
offerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// User who initiated the trade
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
steamId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Trade state
|
||||
state: {
|
||||
type: String,
|
||||
enum: [
|
||||
"pending", // Trade offer sent, awaiting user acceptance
|
||||
"accepted", // User accepted, items transferred
|
||||
"declined", // User declined the offer
|
||||
"expired", // Trade offer expired
|
||||
"canceled", // Trade was canceled (by us or user)
|
||||
"failed", // Trade failed (invalid items, error, etc.)
|
||||
"escrow", // Trade in escrow
|
||||
],
|
||||
default: "pending",
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Items involved in the trade
|
||||
items: [
|
||||
{
|
||||
assetId: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
image: { type: String },
|
||||
game: { type: String, enum: ["cs2", "rust"], required: true },
|
||||
price: { type: Number, required: true },
|
||||
marketPrice: { type: Number },
|
||||
category: { type: String },
|
||||
rarity: { type: String },
|
||||
wear: { type: String },
|
||||
statTrak: { type: Boolean, default: false },
|
||||
souvenir: { type: Boolean, default: false },
|
||||
phase: { type: String },
|
||||
},
|
||||
],
|
||||
|
||||
// Financial information
|
||||
totalValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
|
||||
fee: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
feePercentage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
userReceives: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// User's trade URL used
|
||||
tradeUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Steam trade offer URL
|
||||
tradeOfferUrl: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
// Verification code (shown on site and in trade message)
|
||||
verificationCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
sentAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true,
|
||||
},
|
||||
|
||||
acceptedAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
completedAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
failedAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
// Error information
|
||||
errorMessage: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
errorCode: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
// Transaction reference (created after trade completes)
|
||||
transactionId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Transaction",
|
||||
},
|
||||
|
||||
// Session tracking
|
||||
sessionId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Session",
|
||||
},
|
||||
|
||||
// Bot information
|
||||
botId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
|
||||
botUsername: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
// Retry tracking
|
||||
retryCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
lastRetryAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
// Metadata
|
||||
metadata: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
},
|
||||
|
||||
// Notes (internal use)
|
||||
notes: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
collection: "trades",
|
||||
}
|
||||
);
|
||||
|
||||
// Indexes for common queries
|
||||
tradeSchema.index({ userId: 1, state: 1 });
|
||||
tradeSchema.index({ state: 1, sentAt: -1 });
|
||||
tradeSchema.index({ steamId: 1, state: 1 });
|
||||
tradeSchema.index({ sessionId: 1 });
|
||||
tradeSchema.index({ createdAt: -1 });
|
||||
|
||||
// Virtual for formatted total value
|
||||
tradeSchema.virtual("formattedTotal").get(function () {
|
||||
return `$${this.totalValue.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// Virtual for formatted user receives
|
||||
tradeSchema.virtual("formattedUserReceives").get(function () {
|
||||
return `$${this.userReceives.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// Virtual for item count
|
||||
tradeSchema.virtual("itemCount").get(function () {
|
||||
return this.items.length;
|
||||
});
|
||||
|
||||
// Virtual for time elapsed
|
||||
tradeSchema.virtual("timeElapsed").get(function () {
|
||||
const now = new Date();
|
||||
const start = this.sentAt || this.createdAt;
|
||||
const diff = now - start;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
});
|
||||
|
||||
// Virtual for is expired
|
||||
tradeSchema.virtual("isExpired").get(function () {
|
||||
if (!this.expiresAt) return false;
|
||||
return new Date() > this.expiresAt;
|
||||
});
|
||||
|
||||
// Virtual for is pending
|
||||
tradeSchema.virtual("isPending").get(function () {
|
||||
return this.state === "pending";
|
||||
});
|
||||
|
||||
// Instance methods
|
||||
|
||||
/**
|
||||
* Mark trade as accepted
|
||||
*/
|
||||
tradeSchema.methods.markAsAccepted = async function () {
|
||||
this.state = "accepted";
|
||||
this.acceptedAt = new Date();
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark trade as completed (after transaction created)
|
||||
*/
|
||||
tradeSchema.methods.markAsCompleted = async function (transactionId) {
|
||||
this.state = "accepted";
|
||||
this.completedAt = new Date();
|
||||
if (transactionId) {
|
||||
this.transactionId = transactionId;
|
||||
}
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark trade as failed
|
||||
*/
|
||||
tradeSchema.methods.markAsFailed = async function (errorMessage, errorCode) {
|
||||
this.state = "failed";
|
||||
this.failedAt = new Date();
|
||||
this.errorMessage = errorMessage;
|
||||
if (errorCode) {
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark trade as declined
|
||||
*/
|
||||
tradeSchema.methods.markAsDeclined = async function () {
|
||||
this.state = "declined";
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark trade as expired
|
||||
*/
|
||||
tradeSchema.methods.markAsExpired = async function () {
|
||||
this.state = "expired";
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark trade as canceled
|
||||
*/
|
||||
tradeSchema.methods.markAsCanceled = async function () {
|
||||
this.state = "canceled";
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Increment retry count
|
||||
*/
|
||||
tradeSchema.methods.incrementRetry = async function () {
|
||||
this.retryCount += 1;
|
||||
this.lastRetryAt = new Date();
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Add note
|
||||
*/
|
||||
tradeSchema.methods.addNote = async function (note) {
|
||||
this.notes = this.notes ? `${this.notes}\n${note}` : note;
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
// Static methods
|
||||
|
||||
/**
|
||||
* Create a new trade record
|
||||
*/
|
||||
tradeSchema.statics.createTrade = async function (data) {
|
||||
const trade = new this({
|
||||
offerId: data.offerId,
|
||||
userId: data.userId,
|
||||
steamId: data.steamId,
|
||||
state: data.state || "pending",
|
||||
items: data.items,
|
||||
totalValue: data.totalValue,
|
||||
fee: data.fee || 0,
|
||||
feePercentage: data.feePercentage || 0,
|
||||
userReceives: data.userReceives,
|
||||
tradeUrl: data.tradeUrl,
|
||||
tradeOfferUrl: data.tradeOfferUrl,
|
||||
verificationCode: data.verificationCode,
|
||||
sentAt: data.sentAt || new Date(),
|
||||
expiresAt: data.expiresAt,
|
||||
sessionId: data.sessionId,
|
||||
botId: data.botId,
|
||||
botUsername: data.botUsername,
|
||||
metadata: data.metadata,
|
||||
});
|
||||
|
||||
return await trade.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's trades
|
||||
*/
|
||||
tradeSchema.statics.getUserTrades = async function (userId, options = {}) {
|
||||
const {
|
||||
limit = 50,
|
||||
skip = 0,
|
||||
state = null,
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
} = options;
|
||||
|
||||
const query = { userId };
|
||||
|
||||
if (state) query.state = state;
|
||||
if (startDate || endDate) {
|
||||
query.sentAt = {};
|
||||
if (startDate) query.sentAt.$gte = new Date(startDate);
|
||||
if (endDate) query.sentAt.$lte = new Date(endDate);
|
||||
}
|
||||
|
||||
return await this.find(query)
|
||||
.sort({ sentAt: -1 })
|
||||
.limit(limit)
|
||||
.skip(skip)
|
||||
.populate("userId", "username steamId avatar")
|
||||
.populate("transactionId")
|
||||
.exec();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get trade by offer ID
|
||||
*/
|
||||
tradeSchema.statics.getByOfferId = async function (offerId) {
|
||||
return await this.findOne({ offerId })
|
||||
.populate("userId", "username steamId avatar")
|
||||
.populate("transactionId")
|
||||
.exec();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get pending trades
|
||||
*/
|
||||
tradeSchema.statics.getPendingTrades = async function (limit = 100) {
|
||||
return await this.find({ state: "pending" })
|
||||
.sort({ sentAt: -1 })
|
||||
.limit(limit)
|
||||
.populate("userId", "username steamId avatar")
|
||||
.exec();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get expired trades that need cleanup
|
||||
*/
|
||||
tradeSchema.statics.getExpiredTrades = async function () {
|
||||
const now = new Date();
|
||||
return await this.find({
|
||||
state: "pending",
|
||||
expiresAt: { $lt: now },
|
||||
}).exec();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get trade statistics
|
||||
*/
|
||||
tradeSchema.statics.getStats = async function (userId = null) {
|
||||
const match = userId ? { userId: new mongoose.Types.ObjectId(userId) } : {};
|
||||
|
||||
const stats = await this.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: "$state",
|
||||
count: { $sum: 1 },
|
||||
totalValue: { $sum: "$totalValue" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
accepted: 0,
|
||||
declined: 0,
|
||||
expired: 0,
|
||||
canceled: 0,
|
||||
failed: 0,
|
||||
totalValue: 0,
|
||||
acceptedValue: 0,
|
||||
};
|
||||
|
||||
stats.forEach((stat) => {
|
||||
result.total += stat.count;
|
||||
result.totalValue += stat.totalValue;
|
||||
|
||||
if (stat._id === "accepted") {
|
||||
result.accepted = stat.count;
|
||||
result.acceptedValue = stat.totalValue;
|
||||
} else if (stat._id === "pending") {
|
||||
result.pending = stat.count;
|
||||
} else if (stat._id === "declined") {
|
||||
result.declined = stat.count;
|
||||
} else if (stat._id === "expired") {
|
||||
result.expired = stat.count;
|
||||
} else if (stat._id === "canceled") {
|
||||
result.canceled = stat.count;
|
||||
} else if (stat._id === "failed") {
|
||||
result.failed = stat.count;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup old completed trades
|
||||
*/
|
||||
tradeSchema.statics.cleanupOldTrades = async function (daysOld = 30) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
const result = await this.deleteMany({
|
||||
state: { $in: ["accepted", "declined", "expired", "canceled", "failed"] },
|
||||
completedAt: { $lt: cutoffDate },
|
||||
});
|
||||
|
||||
return result.deletedCount;
|
||||
};
|
||||
|
||||
// Pre-save hook
|
||||
tradeSchema.pre("save", function (next) {
|
||||
// Set expires at if not set (default 10 minutes)
|
||||
if (!this.expiresAt && this.state === "pending") {
|
||||
this.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Ensure virtuals are included in JSON
|
||||
tradeSchema.set("toJSON", { virtuals: true });
|
||||
tradeSchema.set("toObject", { virtuals: true });
|
||||
|
||||
const Trade = mongoose.model("Trade", tradeSchema);
|
||||
|
||||
export default Trade;
|
||||
370
models/Transaction.js
Normal file
370
models/Transaction.js
Normal file
@@ -0,0 +1,370 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const transactionSchema = new mongoose.Schema(
|
||||
{
|
||||
// User information
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
steamId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Transaction type
|
||||
type: {
|
||||
type: String,
|
||||
enum: [
|
||||
"deposit",
|
||||
"withdrawal",
|
||||
"purchase",
|
||||
"sale",
|
||||
"trade",
|
||||
"bonus",
|
||||
"refund",
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Transaction status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["pending", "completed", "failed", "cancelled", "processing"],
|
||||
required: true,
|
||||
default: "pending",
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Amount
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Currency (for future multi-currency support)
|
||||
currency: {
|
||||
type: String,
|
||||
default: "USD",
|
||||
},
|
||||
|
||||
// Balance before and after (for audit trail)
|
||||
balanceBefore: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
balanceAfter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Session tracking (for security)
|
||||
sessionId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Session",
|
||||
required: false, // Optional for backwards compatibility
|
||||
index: true,
|
||||
},
|
||||
sessionIdShort: {
|
||||
type: String, // Last 6 chars of session ID for display
|
||||
required: false,
|
||||
},
|
||||
|
||||
// Related entities
|
||||
itemId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Item",
|
||||
required: false, // Only for purchase/sale transactions
|
||||
},
|
||||
itemName: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
itemImage: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
|
||||
// Payment method (for deposits/withdrawals)
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
enum: ["stripe", "paypal", "crypto", "balance", "steam", "other", null],
|
||||
required: false,
|
||||
},
|
||||
|
||||
// External payment reference
|
||||
externalId: {
|
||||
type: String,
|
||||
required: false,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Description and notes
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
required: false, // Internal notes (not visible to user)
|
||||
},
|
||||
|
||||
// Fee information
|
||||
fee: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
feePercentage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
completedAt: {
|
||||
type: Date,
|
||||
required: false,
|
||||
},
|
||||
failedAt: {
|
||||
type: Date,
|
||||
required: false,
|
||||
},
|
||||
cancelledAt: {
|
||||
type: Date,
|
||||
required: false,
|
||||
},
|
||||
|
||||
// Error information (if failed)
|
||||
errorMessage: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
|
||||
// Metadata (flexible field for additional data)
|
||||
metadata: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true, // Adds createdAt and updatedAt
|
||||
collection: "transactions",
|
||||
}
|
||||
);
|
||||
|
||||
// Indexes for common queries
|
||||
transactionSchema.index({ userId: 1, createdAt: -1 });
|
||||
transactionSchema.index({ steamId: 1, createdAt: -1 });
|
||||
transactionSchema.index({ type: 1, status: 1 });
|
||||
transactionSchema.index({ sessionId: 1, createdAt: -1 });
|
||||
transactionSchema.index({ status: 1, createdAt: -1 });
|
||||
|
||||
// Virtual for formatted amount
|
||||
transactionSchema.virtual("formattedAmount").get(function () {
|
||||
return `$${this.amount.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// Virtual for transaction direction (+ or -)
|
||||
transactionSchema.virtual("direction").get(function () {
|
||||
const positiveTypes = ["deposit", "sale", "bonus", "refund"];
|
||||
return positiveTypes.includes(this.type) ? "+" : "-";
|
||||
});
|
||||
|
||||
// Virtual for session color (deterministic based on sessionIdShort)
|
||||
transactionSchema.virtual("sessionColor").get(function () {
|
||||
if (!this.sessionIdShort) return "#64748b";
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < this.sessionIdShort.length; i++) {
|
||||
hash = this.sessionIdShort.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const saturation = 60 + (Math.abs(hash) % 20);
|
||||
const lightness = 45 + (Math.abs(hash) % 15);
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
});
|
||||
|
||||
// Instance methods
|
||||
|
||||
/**
|
||||
* Mark transaction as completed
|
||||
*/
|
||||
transactionSchema.methods.complete = async function () {
|
||||
this.status = "completed";
|
||||
this.completedAt = new Date();
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark transaction as failed
|
||||
*/
|
||||
transactionSchema.methods.fail = async function (errorMessage, errorCode) {
|
||||
this.status = "failed";
|
||||
this.failedAt = new Date();
|
||||
this.errorMessage = errorMessage;
|
||||
this.errorCode = errorCode;
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark transaction as cancelled
|
||||
*/
|
||||
transactionSchema.methods.cancel = async function () {
|
||||
this.status = "cancelled";
|
||||
this.cancelledAt = new Date();
|
||||
return await this.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get session ID short (last 6 chars)
|
||||
*/
|
||||
transactionSchema.methods.getSessionIdShort = function () {
|
||||
if (!this.sessionId) return "SYSTEM";
|
||||
return this.sessionId.toString().slice(-6).toUpperCase();
|
||||
};
|
||||
|
||||
// Static methods
|
||||
|
||||
/**
|
||||
* Create a new transaction with session tracking
|
||||
*/
|
||||
transactionSchema.statics.createTransaction = async function (data) {
|
||||
const transaction = new this({
|
||||
userId: data.userId,
|
||||
steamId: data.steamId,
|
||||
type: data.type,
|
||||
status: data.status || "completed",
|
||||
amount: data.amount,
|
||||
currency: data.currency || "USD",
|
||||
balanceBefore: data.balanceBefore || 0,
|
||||
balanceAfter: data.balanceAfter || 0,
|
||||
sessionId: data.sessionId,
|
||||
sessionIdShort: data.sessionId
|
||||
? data.sessionId.toString().slice(-6).toUpperCase()
|
||||
: "SYSTEM",
|
||||
itemId: data.itemId,
|
||||
itemName: data.itemName,
|
||||
paymentMethod: data.paymentMethod,
|
||||
description: data.description,
|
||||
notes: data.notes,
|
||||
fee: data.fee || 0,
|
||||
feePercentage: data.feePercentage || 0,
|
||||
metadata: data.metadata,
|
||||
});
|
||||
|
||||
return await transaction.save();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's transaction history
|
||||
*/
|
||||
transactionSchema.statics.getUserTransactions = async function (
|
||||
userId,
|
||||
options = {}
|
||||
) {
|
||||
const {
|
||||
limit = 50,
|
||||
skip = 0,
|
||||
type = null,
|
||||
status = null,
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
} = options;
|
||||
|
||||
const query = { userId };
|
||||
|
||||
if (type) query.type = type;
|
||||
if (status) query.status = status;
|
||||
if (startDate || endDate) {
|
||||
query.createdAt = {};
|
||||
if (startDate) query.createdAt.$gte = new Date(startDate);
|
||||
if (endDate) query.createdAt.$lte = new Date(endDate);
|
||||
}
|
||||
|
||||
return await this.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.skip(skip)
|
||||
.populate("sessionId", "device browser os ip")
|
||||
.exec();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get transactions by session
|
||||
*/
|
||||
transactionSchema.statics.getSessionTransactions = async function (sessionId) {
|
||||
return await this.find({ sessionId })
|
||||
.sort({ createdAt: -1 })
|
||||
.populate("itemId", "name rarity game")
|
||||
.exec();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's transaction statistics
|
||||
*/
|
||||
transactionSchema.statics.getUserStats = async function (userId) {
|
||||
const stats = await this.aggregate([
|
||||
{ $match: { userId: new mongoose.Types.ObjectId(userId) } },
|
||||
{
|
||||
$group: {
|
||||
_id: "$type",
|
||||
count: { $sum: 1 },
|
||||
totalAmount: { $sum: "$amount" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = {
|
||||
totalDeposits: 0,
|
||||
totalWithdrawals: 0,
|
||||
totalPurchases: 0,
|
||||
totalSales: 0,
|
||||
depositCount: 0,
|
||||
withdrawalCount: 0,
|
||||
purchaseCount: 0,
|
||||
saleCount: 0,
|
||||
};
|
||||
|
||||
stats.forEach((stat) => {
|
||||
if (stat._id === "deposit") {
|
||||
result.totalDeposits = stat.totalAmount;
|
||||
result.depositCount = stat.count;
|
||||
} else if (stat._id === "withdrawal") {
|
||||
result.totalWithdrawals = stat.totalAmount;
|
||||
result.withdrawalCount = stat.count;
|
||||
} else if (stat._id === "purchase") {
|
||||
result.totalPurchases = stat.totalAmount;
|
||||
result.purchaseCount = stat.count;
|
||||
} else if (stat._id === "sale") {
|
||||
result.totalSales = stat.totalAmount;
|
||||
result.saleCount = stat.count;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Pre-save hook to set sessionIdShort
|
||||
transactionSchema.pre("save", function (next) {
|
||||
if (this.sessionId && !this.sessionIdShort) {
|
||||
this.sessionIdShort = this.sessionId.toString().slice(-6).toUpperCase();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Ensure virtuals are included in JSON
|
||||
transactionSchema.set("toJSON", { virtuals: true });
|
||||
transactionSchema.set("toObject", { virtuals: true });
|
||||
|
||||
const Transaction = mongoose.model("Transaction", transactionSchema);
|
||||
|
||||
export default Transaction;
|
||||
43
models/User.js
Normal file
43
models/User.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const UserSchema = new mongoose.Schema(
|
||||
{
|
||||
username: String,
|
||||
steamId: String,
|
||||
avatar: String,
|
||||
tradeUrl: { type: String, default: null },
|
||||
account_creation: Number,
|
||||
communityvisibilitystate: Number,
|
||||
balance: { type: Number, default: 0 },
|
||||
intercom: { type: String, default: null },
|
||||
email: {
|
||||
address: { type: String, default: null },
|
||||
verified: { type: Boolean, default: false },
|
||||
emailToken: { type: String, default: null },
|
||||
},
|
||||
ban: {
|
||||
banned: { type: Boolean, default: false },
|
||||
reason: { type: String, default: null },
|
||||
expires: { type: Date, default: null },
|
||||
},
|
||||
staffLevel: { type: Number, default: 0 },
|
||||
twoFactor: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
qrCode: { type: String, default: null },
|
||||
secret: { type: String, default: null },
|
||||
revocationCode: { type: String, default: null },
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
// Virtual property for admin check
|
||||
UserSchema.virtual("isAdmin").get(function () {
|
||||
return this.staffLevel >= 3; // Staff level 3 or higher is admin
|
||||
});
|
||||
|
||||
// Ensure virtuals are included in JSON
|
||||
UserSchema.set("toJSON", { virtuals: true });
|
||||
UserSchema.set("toObject", { virtuals: true });
|
||||
|
||||
export default mongoose.model("User", UserSchema);
|
||||
Reference in New Issue
Block a user