Initial commit
This commit is contained in:
109
server/models/AlertLog.js
Normal file
109
server/models/AlertLog.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const AlertLog = sequelize.define('AlertLog', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
alert_rule_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'alert_rules',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
detection_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'drone_detections',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
alert_type: {
|
||||
type: DataTypes.ENUM('sms', 'email', 'webhook', 'push'),
|
||||
allowNull: false
|
||||
},
|
||||
recipient: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Phone number, email, or webhook URL'
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
comment: 'Alert message content'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'sent', 'failed', 'delivered'),
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
sent_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
delivered_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Error message if alert failed'
|
||||
},
|
||||
external_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'External service message ID (Twilio SID, etc.)'
|
||||
},
|
||||
cost: {
|
||||
type: DataTypes.DECIMAL(6, 4),
|
||||
allowNull: true,
|
||||
comment: 'Cost of sending the alert (for SMS, etc.)'
|
||||
},
|
||||
retry_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of retry attempts'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
|
||||
defaultValue: 'medium'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'alert_logs',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['alert_rule_id']
|
||||
},
|
||||
{
|
||||
fields: ['detection_id']
|
||||
},
|
||||
{
|
||||
fields: ['status']
|
||||
},
|
||||
{
|
||||
fields: ['sent_at']
|
||||
},
|
||||
{
|
||||
fields: ['alert_type', 'status']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return AlertLog;
|
||||
};
|
||||
124
server/models/AlertRule.js
Normal file
124
server/models/AlertRule.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const AlertRule = sequelize.define('AlertRule', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Human-readable name for the alert rule'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Description of what triggers this alert'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
device_ids: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of device IDs to monitor (null = all devices)'
|
||||
},
|
||||
drone_types: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of drone types to alert on (null = all types)'
|
||||
},
|
||||
min_rssi: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Minimum RSSI threshold for alert'
|
||||
},
|
||||
max_rssi: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Maximum RSSI threshold for alert'
|
||||
},
|
||||
frequency_ranges: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of frequency ranges to monitor [{min: 20, max: 30}]'
|
||||
},
|
||||
time_window: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 300,
|
||||
comment: 'Time window in seconds to group detections'
|
||||
},
|
||||
min_detections: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1,
|
||||
comment: 'Minimum number of detections in time window to trigger alert'
|
||||
},
|
||||
cooldown_period: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 600,
|
||||
comment: 'Cooldown period in seconds between alerts for same drone'
|
||||
},
|
||||
alert_channels: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: ['sms'],
|
||||
comment: 'Array of alert channels: sms, email, webhook'
|
||||
},
|
||||
webhook_url: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Webhook URL for custom integrations'
|
||||
},
|
||||
active_hours: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Active hours for alerts {start: "09:00", end: "17:00"}'
|
||||
},
|
||||
active_days: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: [1, 2, 3, 4, 5, 6, 7],
|
||||
comment: 'Active days of week (1=Monday, 7=Sunday)'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
|
||||
defaultValue: 'medium',
|
||||
comment: 'Alert priority level'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'alert_rules',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
},
|
||||
{
|
||||
fields: ['priority']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return AlertRule;
|
||||
};
|
||||
88
server/models/Device.js
Normal file
88
server/models/Device.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Device = sequelize.define('Device', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'Unique device identifier from hardware'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Human-readable device name'
|
||||
},
|
||||
geo_lat: {
|
||||
type: DataTypes.DECIMAL(10, 8),
|
||||
allowNull: true,
|
||||
comment: 'Device latitude coordinate'
|
||||
},
|
||||
geo_lon: {
|
||||
type: DataTypes.DECIMAL(11, 8),
|
||||
allowNull: true,
|
||||
comment: 'Device longitude coordinate'
|
||||
},
|
||||
location_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Human-readable location description'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: 'Whether the device is currently active'
|
||||
},
|
||||
last_heartbeat: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Timestamp of last heartbeat received'
|
||||
},
|
||||
heartbeat_interval: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 300, // 5 minutes
|
||||
comment: 'Expected heartbeat interval in seconds'
|
||||
},
|
||||
firmware_version: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Device firmware version'
|
||||
},
|
||||
installation_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When the device was installed'
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Additional notes about the device'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'devices',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['geo_lat', 'geo_lon']
|
||||
},
|
||||
{
|
||||
fields: ['last_heartbeat']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return Device;
|
||||
};
|
||||
120
server/models/DroneDetection.js
Normal file
120
server/models/DroneDetection.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const DroneDetection = sequelize.define('DroneDetection', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
device_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'ID of the detecting device'
|
||||
},
|
||||
drone_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: 'Detected drone identifier'
|
||||
},
|
||||
drone_type: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Type of drone detected (0=unknown, 1=commercial, 2=racing, etc.)'
|
||||
},
|
||||
rssi: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Received Signal Strength Indicator'
|
||||
},
|
||||
freq: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: 'Frequency detected'
|
||||
},
|
||||
geo_lat: {
|
||||
type: DataTypes.DECIMAL(10, 8),
|
||||
allowNull: true,
|
||||
comment: 'Latitude where detection occurred'
|
||||
},
|
||||
geo_lon: {
|
||||
type: DataTypes.DECIMAL(11, 8),
|
||||
allowNull: true,
|
||||
comment: 'Longitude where detection occurred'
|
||||
},
|
||||
device_timestamp: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
comment: 'Unix timestamp from the device'
|
||||
},
|
||||
server_timestamp: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: 'When the detection was received by server'
|
||||
},
|
||||
confidence_level: {
|
||||
type: DataTypes.DECIMAL(3, 2),
|
||||
allowNull: true,
|
||||
comment: 'Confidence level of detection (0.00-1.00)'
|
||||
},
|
||||
signal_duration: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Duration of signal in milliseconds'
|
||||
},
|
||||
processed: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether this detection has been processed for alerts'
|
||||
},
|
||||
threat_level: {
|
||||
type: DataTypes.ENUM('monitoring', 'low', 'medium', 'high', 'critical'),
|
||||
allowNull: true,
|
||||
comment: 'Assessed threat level based on RSSI and drone type'
|
||||
},
|
||||
estimated_distance: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Estimated distance to drone in meters'
|
||||
},
|
||||
requires_action: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether this detection requires immediate security action'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'drone_detections',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['device_id']
|
||||
},
|
||||
{
|
||||
fields: ['drone_id']
|
||||
},
|
||||
{
|
||||
fields: ['server_timestamp']
|
||||
},
|
||||
{
|
||||
fields: ['processed']
|
||||
},
|
||||
{
|
||||
fields: ['device_id', 'drone_id', 'server_timestamp']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return DroneDetection;
|
||||
};
|
||||
82
server/models/Heartbeat.js
Normal file
82
server/models/Heartbeat.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Heartbeat = sequelize.define('Heartbeat', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
device_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'ID of the device sending heartbeat'
|
||||
},
|
||||
device_key: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Unique key of the sensor from heartbeat message'
|
||||
},
|
||||
signal_strength: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Signal strength at time of heartbeat'
|
||||
},
|
||||
battery_level: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Battery level percentage (0-100)'
|
||||
},
|
||||
temperature: {
|
||||
type: DataTypes.DECIMAL(4, 1),
|
||||
allowNull: true,
|
||||
comment: 'Device temperature in Celsius'
|
||||
},
|
||||
uptime: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
comment: 'Device uptime in seconds'
|
||||
},
|
||||
memory_usage: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Memory usage percentage'
|
||||
},
|
||||
firmware_version: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Firmware version reported in heartbeat'
|
||||
},
|
||||
received_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: 'When heartbeat was received by server'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'heartbeats',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['device_id']
|
||||
},
|
||||
{
|
||||
fields: ['received_at']
|
||||
},
|
||||
{
|
||||
fields: ['device_id', 'received_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return Heartbeat;
|
||||
};
|
||||
98
server/models/User.js
Normal file
98
server/models/User.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const User = sequelize.define('User', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
len: [3, 50]
|
||||
}
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true
|
||||
}
|
||||
},
|
||||
password_hash: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
first_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
last_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
phone_number: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Phone number for SMS alerts (include country code)'
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.ENUM('admin', 'operator', 'viewer'),
|
||||
defaultValue: 'viewer',
|
||||
comment: 'User role for permission management'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
sms_alerts_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether user wants to receive SMS alerts'
|
||||
},
|
||||
email_alerts_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: 'Whether user wants to receive email alerts'
|
||||
},
|
||||
last_login: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
timezone: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'UTC',
|
||||
comment: 'User timezone for alert scheduling'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'users',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['email']
|
||||
},
|
||||
{
|
||||
fields: ['username']
|
||||
},
|
||||
{
|
||||
fields: ['phone_number']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return User;
|
||||
};
|
||||
54
server/models/index.js
Normal file
54
server/models/index.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
require('dotenv').config();
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'drone_detection',
|
||||
process.env.DB_USER || 'postgres',
|
||||
process.env.DB_PASSWORD || 'password',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: 'postgres',
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
pool: {
|
||||
max: 5,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Import models
|
||||
const Device = require('./Device')(sequelize);
|
||||
const DroneDetection = require('./DroneDetection')(sequelize);
|
||||
const Heartbeat = require('./Heartbeat')(sequelize);
|
||||
const User = require('./User')(sequelize);
|
||||
const AlertRule = require('./AlertRule')(sequelize);
|
||||
const AlertLog = require('./AlertLog')(sequelize);
|
||||
|
||||
// Define associations
|
||||
Device.hasMany(DroneDetection, { foreignKey: 'device_id', as: 'detections' });
|
||||
DroneDetection.belongsTo(Device, { foreignKey: 'device_id', as: 'device' });
|
||||
|
||||
Device.hasMany(Heartbeat, { foreignKey: 'device_id', as: 'heartbeats' });
|
||||
Heartbeat.belongsTo(Device, { foreignKey: 'device_id', as: 'device' });
|
||||
|
||||
User.hasMany(AlertRule, { foreignKey: 'user_id', as: 'alertRules' });
|
||||
AlertRule.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
AlertRule.hasMany(AlertLog, { foreignKey: 'alert_rule_id', as: 'logs' });
|
||||
AlertLog.belongsTo(AlertRule, { foreignKey: 'alert_rule_id', as: 'rule' });
|
||||
|
||||
DroneDetection.hasMany(AlertLog, { foreignKey: 'detection_id', as: 'alerts' });
|
||||
AlertLog.belongsTo(DroneDetection, { foreignKey: 'detection_id', as: 'detection' });
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
Device,
|
||||
DroneDetection,
|
||||
Heartbeat,
|
||||
User,
|
||||
AlertRule,
|
||||
AlertLog
|
||||
};
|
||||
Reference in New Issue
Block a user