diff --git a/database/sql/001_create_tables.sql b/database/sql/001_create_tables.sql
new file mode 100644
index 0000000..cfee90e
--- /dev/null
+++ b/database/sql/001_create_tables.sql
@@ -0,0 +1,100 @@
+-- ============================================================================
+-- FantasyRealm Character Manager - Database Schema
+-- PostgreSQL 15+
+-- ============================================================================
+
+-- ============================================================================
+-- TABLE: role
+-- User roles for authorization (user, employee, admin)
+-- ============================================================================
+CREATE TABLE role (
+ id SERIAL PRIMARY KEY,
+ label VARCHAR(50) NOT NULL UNIQUE
+);
+
+-- ============================================================================
+-- TABLE: user
+-- Registered users of the application
+-- ============================================================================
+CREATE TABLE "user" (
+ id SERIAL PRIMARY KEY,
+ pseudo VARCHAR(50) NOT NULL UNIQUE,
+ email VARCHAR(100) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ is_suspended BOOLEAN NOT NULL DEFAULT FALSE,
+ must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
+ role_id INTEGER NOT NULL REFERENCES role(id)
+);
+
+CREATE INDEX idx_user_email ON "user"(email);
+CREATE INDEX idx_user_pseudo ON "user"(pseudo);
+
+-- ============================================================================
+-- TABLE: character
+-- Player characters created by users
+-- ============================================================================
+CREATE TABLE character (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(50) NOT NULL,
+ gender VARCHAR(20) NOT NULL CHECK (gender IN ('male', 'female')),
+ skin_color VARCHAR(7) NOT NULL,
+ eye_color VARCHAR(7) NOT NULL,
+ hair_color VARCHAR(7) NOT NULL,
+ eye_shape VARCHAR(50) NOT NULL,
+ nose_shape VARCHAR(50) NOT NULL,
+ mouth_shape VARCHAR(50) NOT NULL,
+ image TEXT,
+ is_shared BOOLEAN NOT NULL DEFAULT FALSE,
+ is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
+ user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+
+ CONSTRAINT uq_character_name_per_user UNIQUE (name, user_id)
+);
+
+CREATE INDEX idx_character_user_id ON character(user_id);
+
+-- ============================================================================
+-- TABLE: article
+-- Customization items (clothing, armor, weapons, accessories)
+-- ============================================================================
+CREATE TABLE article (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ type VARCHAR(20) NOT NULL CHECK (type IN ('clothing', 'armor', 'weapon', 'accessory')),
+ image TEXT,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+CREATE INDEX idx_article_type ON article(type);
+CREATE INDEX idx_article_is_active ON article(is_active);
+
+-- ============================================================================
+-- TABLE: character_article
+-- Many-to-many relationship between characters and equipped articles
+-- ============================================================================
+CREATE TABLE character_article (
+ character_id INTEGER NOT NULL REFERENCES character(id) ON DELETE CASCADE,
+ article_id INTEGER NOT NULL REFERENCES article(id) ON DELETE CASCADE,
+
+ PRIMARY KEY (character_id, article_id)
+);
+
+-- ============================================================================
+-- TABLE: comment
+-- User reviews on shared characters
+-- ============================================================================
+CREATE TABLE comment (
+ id SERIAL PRIMARY KEY,
+ rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
+ text TEXT NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved')),
+ commented_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ character_id INTEGER NOT NULL REFERENCES character(id) ON DELETE CASCADE,
+ author_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+
+ CONSTRAINT uq_one_comment_per_user_per_character UNIQUE (character_id, author_id)
+);
+
+CREATE INDEX idx_comment_character_id ON comment(character_id);
+CREATE INDEX idx_comment_author_id ON comment(author_id);
+CREATE INDEX idx_comment_status ON comment(status);
diff --git a/database/sql/002_seed_data.sql b/database/sql/002_seed_data.sql
new file mode 100644
index 0000000..66c4834
--- /dev/null
+++ b/database/sql/002_seed_data.sql
@@ -0,0 +1,117 @@
+-- ============================================================================
+-- FantasyRealm Character Manager - Seed Data
+-- PostgreSQL 15+
+-- ============================================================================
+
+-- ============================================================================
+-- ROLES
+-- ============================================================================
+INSERT INTO role (label) VALUES
+ ('user'),
+ ('employee'),
+ ('admin');
+
+-- ============================================================================
+-- ADMIN USER
+-- Password: Admin123! (BCrypt hash)
+-- ============================================================================
+INSERT INTO "user" (pseudo, email, password_hash, role_id) VALUES
+ ('admin', 'admin@fantasyrealm.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'admin'));
+
+-- ============================================================================
+-- EMPLOYEE USER (for testing moderation)
+-- Password: Employee123!
+-- ============================================================================
+INSERT INTO "user" (pseudo, email, password_hash, role_id) VALUES
+ ('moderator', 'moderator@fantasyrealm.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'employee'));
+
+-- ============================================================================
+-- TEST USERS
+-- Password: Test123!
+-- ============================================================================
+INSERT INTO "user" (pseudo, email, password_hash, role_id) VALUES
+ ('player_one', 'player1@test.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'user')),
+ ('dragon_slayer', 'player2@test.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'user')),
+ ('mystic_mage', 'player3@test.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'user'));
+
+-- ============================================================================
+-- ARTICLES - Clothing
+-- ============================================================================
+INSERT INTO article (name, type, image) VALUES
+ ('Apprentice Robe', 'clothing', NULL),
+ ('Leather Tunic', 'clothing', NULL),
+ ('Noble Vest', 'clothing', NULL),
+ ('Traveler Cloak', 'clothing', NULL),
+ ('Silk Dress', 'clothing', NULL);
+
+-- ============================================================================
+-- ARTICLES - Armor
+-- ============================================================================
+INSERT INTO article (name, type, image) VALUES
+ ('Iron Chestplate', 'armor', NULL),
+ ('Steel Helmet', 'armor', NULL),
+ ('Dragon Scale Armor', 'armor', NULL),
+ ('Mithril Chainmail', 'armor', NULL),
+ ('Guardian Shield', 'armor', NULL);
+
+-- ============================================================================
+-- ARTICLES - Weapons
+-- ============================================================================
+INSERT INTO article (name, type, image) VALUES
+ ('Iron Sword', 'weapon', NULL),
+ ('Oak Staff', 'weapon', NULL),
+ ('Elven Bow', 'weapon', NULL),
+ ('Battle Axe', 'weapon', NULL),
+ ('Enchanted Dagger', 'weapon', NULL);
+
+-- ============================================================================
+-- ARTICLES - Accessories
+-- ============================================================================
+INSERT INTO article (name, type, image) VALUES
+ ('Ruby Amulet', 'accessory', NULL),
+ ('Silver Ring', 'accessory', NULL),
+ ('Leather Belt', 'accessory', NULL),
+ ('Explorer Backpack', 'accessory', NULL),
+ ('Golden Crown', 'accessory', NULL);
+
+-- ============================================================================
+-- TEST CHARACTERS (authorized and shared for gallery demo)
+-- ============================================================================
+INSERT INTO character (name, gender, skin_color, eye_color, hair_color, eye_shape, nose_shape, mouth_shape, is_shared, is_authorized, user_id) VALUES
+ ('Thorin', 'male', '#E8BEAC', '#4A90D9', '#2C1810', 'almond', 'aquiline', 'thin', TRUE, TRUE, (SELECT id FROM "user" WHERE pseudo = 'player_one')),
+ ('Elara', 'female', '#F5DEB3', '#50C878', '#FFD700', 'round', 'straight', 'full', TRUE, TRUE, (SELECT id FROM "user" WHERE pseudo = 'dragon_slayer')),
+ ('Zephyr', 'male', '#8D5524', '#8B4513', '#000000', 'narrow', 'wide', 'medium', TRUE, TRUE, (SELECT id FROM "user" WHERE pseudo = 'mystic_mage'));
+
+-- ============================================================================
+-- TEST CHARACTER (pending moderation)
+-- ============================================================================
+INSERT INTO character (name, gender, skin_color, eye_color, hair_color, eye_shape, nose_shape, mouth_shape, is_shared, is_authorized, user_id) VALUES
+ ('Shadow', 'male', '#3D2314', '#FF0000', '#1C1C1C', 'narrow', 'pointed', 'thin', FALSE, FALSE, (SELECT id FROM "user" WHERE pseudo = 'player_one'));
+
+-- ============================================================================
+-- EQUIP ARTICLES TO CHARACTERS
+-- ============================================================================
+INSERT INTO character_article (character_id, article_id) VALUES
+ ((SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM article WHERE name = 'Iron Chestplate')),
+ ((SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM article WHERE name = 'Iron Sword')),
+ ((SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM article WHERE name = 'Leather Belt')),
+ ((SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM article WHERE name = 'Silk Dress')),
+ ((SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM article WHERE name = 'Elven Bow')),
+ ((SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM article WHERE name = 'Ruby Amulet')),
+ ((SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM article WHERE name = 'Apprentice Robe')),
+ ((SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM article WHERE name = 'Oak Staff')),
+ ((SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM article WHERE name = 'Silver Ring'));
+
+-- ============================================================================
+-- TEST COMMENTS (approved)
+-- ============================================================================
+INSERT INTO comment (rating, text, status, character_id, author_id) VALUES
+ (5, 'Amazing warrior design! Love the armor combo.', 'approved', (SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM "user" WHERE pseudo = 'dragon_slayer')),
+ (4, 'Beautiful elven character, very elegant!', 'approved', (SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM "user" WHERE pseudo = 'player_one')),
+ (5, 'The mage aesthetic is perfect!', 'approved', (SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM "user" WHERE pseudo = 'dragon_slayer'));
+
+-- ============================================================================
+-- TEST COMMENT (pending moderation)
+-- ============================================================================
+INSERT INTO comment (rating, text, status, character_id, author_id) VALUES
+ (3, 'Nice but could use more accessories.', 'pending', (SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM "user" WHERE pseudo = 'mystic_mage'));
diff --git a/src/backend/src/FantasyRealm.Api/Program.cs b/src/backend/src/FantasyRealm.Api/Program.cs
index a741bc3..51a3c71 100644
--- a/src/backend/src/FantasyRealm.Api/Program.cs
+++ b/src/backend/src/FantasyRealm.Api/Program.cs
@@ -1,43 +1,50 @@
-var builder = WebApplication.CreateBuilder(args);
-
-// Add services to the container
-builder.Services.AddControllers();
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
-
-// CORS configuration
-builder.Services.AddCors(options =>
-{
- options.AddPolicy("AllowFrontend", policy =>
- {
- policy.WithOrigins("http://localhost:5173")
- .AllowAnyMethod()
- .AllowAnyHeader()
- .AllowCredentials();
- });
-});
-
-// TODO: Add JWT Authentication (FRO-1)
-// TODO: Add Application services (FRO-15+)
-// TODO: Add Infrastructure services (FRO-17)
-
-var app = builder.Build();
-
-// Configure the HTTP request pipeline
-if (app.Environment.IsDevelopment())
+namespace FantasyRealm.Api
{
- app.UseSwagger();
- app.UseSwaggerUI(options =>
+ public static class Program
{
- options.SwaggerEndpoint("/swagger/v1/swagger.json", "FantasyRealm API v1");
- });
+ private static void Main(string[] args)
+ {
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Add services to the container
+ builder.Services.AddControllers();
+ builder.Services.AddEndpointsApiExplorer();
+ builder.Services.AddSwaggerGen();
+
+ // CORS configuration
+ builder.Services.AddCors(options =>
+ {
+ options.AddPolicy("AllowFrontend", policy =>
+ {
+ policy.WithOrigins("http://localhost:5173")
+ .AllowAnyMethod()
+ .AllowAnyHeader()
+ .AllowCredentials();
+ });
+ });
+
+ // TODO: Add JWT Authentication (FRO-1)
+ // TODO: Add Application services (FRO-15+)
+ // TODO: Add Infrastructure services (FRO-17)
+
+ var app = builder.Build();
+
+ // Configure the HTTP request pipeline
+ if (app.Environment.IsDevelopment())
+ {
+ app.UseSwagger();
+ app.UseSwaggerUI(options =>
+ {
+ options.SwaggerEndpoint("/swagger/v1/swagger.json", "FantasyRealm API v1");
+ });
+ }
+
+ app.UseHttpsRedirection();
+ app.UseCors("AllowFrontend");
+ app.UseAuthorization();
+ app.MapControllers();
+
+ app.Run();
+ }
+ }
}
-
-app.UseHttpsRedirection();
-app.UseCors("AllowFrontend");
-app.UseAuthorization();
-app.MapControllers();
-
-app.Run();
-
-public partial class Program { }
diff --git a/src/backend/src/FantasyRealm.Api/appsettings.json b/src/backend/src/FantasyRealm.Api/appsettings.json
index 10f68b8..50b34bd 100644
--- a/src/backend/src/FantasyRealm.Api/appsettings.json
+++ b/src/backend/src/FantasyRealm.Api/appsettings.json
@@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "PostgreSQL": "",
+ "MongoDB": ""
+ },
+ "MongoDB": {
+ "DatabaseName": "fantasyrealm"
+ }
}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/.gitkeep b/src/backend/src/FantasyRealm.Domain/Entities/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/ActivityLog.cs b/src/backend/src/FantasyRealm.Domain/Entities/ActivityLog.cs
new file mode 100644
index 0000000..c4193bb
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/ActivityLog.cs
@@ -0,0 +1,40 @@
+using FantasyRealm.Domain.Enums;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents an activity log entry stored in MongoDB.
+ ///
+ public class ActivityLog
+ {
+ [BsonId]
+ public ObjectId Id { get; set; }
+
+ [BsonElement("timestamp")]
+ public DateTime Timestamp { get; set; }
+
+ [BsonElement("user_id")]
+ public int UserId { get; set; }
+
+ [BsonElement("user_pseudo")]
+ public string UserPseudo { get; set; } = string.Empty;
+
+ [BsonElement("action")]
+ [BsonRepresentation(BsonType.String)]
+ public ActivityAction Action { get; set; }
+
+ [BsonElement("target_type")]
+ public string TargetType { get; set; } = string.Empty;
+
+ [BsonElement("target_id")]
+ public int TargetId { get; set; }
+
+ [BsonElement("details")]
+ public BsonDocument? Details { get; set; }
+
+ [BsonElement("ip_address")]
+ public string? IpAddress { get; set; }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/Article.cs b/src/backend/src/FantasyRealm.Domain/Entities/Article.cs
new file mode 100644
index 0000000..34c1cec
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/Article.cs
@@ -0,0 +1,22 @@
+using FantasyRealm.Domain.Enums;
+
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents a customization item (clothing, armor, weapon, accessory).
+ ///
+ public class Article
+ {
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public ArticleType Type { get; set; }
+
+ public string? Image { get; set; }
+
+ public bool IsActive { get; set; } = true;
+
+ public ICollection CharacterArticles { get; set; } = [];
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/Character.cs b/src/backend/src/FantasyRealm.Domain/Entities/Character.cs
new file mode 100644
index 0000000..fc9f480
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/Character.cs
@@ -0,0 +1,42 @@
+using FantasyRealm.Domain.Enums;
+
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents a player character created by a user.
+ ///
+ public class Character
+ {
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public Gender Gender { get; set; }
+
+ public string SkinColor { get; set; } = string.Empty;
+
+ public string EyeColor { get; set; } = string.Empty;
+
+ public string HairColor { get; set; } = string.Empty;
+
+ public string EyeShape { get; set; } = string.Empty;
+
+ public string NoseShape { get; set; } = string.Empty;
+
+ public string MouthShape { get; set; } = string.Empty;
+
+ public string? Image { get; set; }
+
+ public bool IsShared { get; set; }
+
+ public bool IsAuthorized { get; set; }
+
+ public int UserId { get; set; }
+
+ public User User { get; set; } = null!;
+
+ public ICollection CharacterArticles { get; set; } = [];
+
+ public ICollection Comments { get; set; } = [];
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/CharacterArticle.cs b/src/backend/src/FantasyRealm.Domain/Entities/CharacterArticle.cs
new file mode 100644
index 0000000..30d549e
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/CharacterArticle.cs
@@ -0,0 +1,16 @@
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents the many-to-many relationship between characters and equipped articles.
+ ///
+ public class CharacterArticle
+ {
+ public int CharacterId { get; set; }
+
+ public Character Character { get; set; } = null!;
+
+ public int ArticleId { get; set; }
+
+ public Article Article { get; set; } = null!;
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/Comment.cs b/src/backend/src/FantasyRealm.Domain/Entities/Comment.cs
new file mode 100644
index 0000000..bd7e18b
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/Comment.cs
@@ -0,0 +1,28 @@
+using FantasyRealm.Domain.Enums;
+
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents a user review on a shared character.
+ ///
+ public class Comment
+ {
+ public int Id { get; set; }
+
+ public int Rating { get; set; }
+
+ public string Text { get; set; } = string.Empty;
+
+ public CommentStatus Status { get; set; }
+
+ public DateTime CommentedAt { get; set; }
+
+ public int CharacterId { get; set; }
+
+ public Character Character { get; set; } = null!;
+
+ public int AuthorId { get; set; }
+
+ public User Author { get; set; } = null!;
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/Role.cs b/src/backend/src/FantasyRealm.Domain/Entities/Role.cs
new file mode 100644
index 0000000..02e33a9
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/Role.cs
@@ -0,0 +1,14 @@
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents a user role for authorization (user, employee, admin).
+ ///
+ public class Role
+ {
+ public int Id { get; set; }
+
+ public string Label { get; set; } = string.Empty;
+
+ public ICollection Users { get; set; } = [];
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Entities/User.cs b/src/backend/src/FantasyRealm.Domain/Entities/User.cs
new file mode 100644
index 0000000..db52e26
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Entities/User.cs
@@ -0,0 +1,28 @@
+namespace FantasyRealm.Domain.Entities
+{
+ ///
+ /// Represents a registered user of the application.
+ ///
+ public class User
+ {
+ public int Id { get; set; }
+
+ public string Pseudo { get; set; } = string.Empty;
+
+ public string Email { get; set; } = string.Empty;
+
+ public string PasswordHash { get; set; } = string.Empty;
+
+ public bool IsSuspended { get; set; }
+
+ public bool MustChangePassword { get; set; }
+
+ public int RoleId { get; set; }
+
+ public Role Role { get; set; } = null!;
+
+ public ICollection Characters { get; set; } = [];
+
+ public ICollection Comments { get; set; } = [];
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Enums/.gitkeep b/src/backend/src/FantasyRealm.Domain/Enums/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/backend/src/FantasyRealm.Domain/Enums/ActivityAction.cs b/src/backend/src/FantasyRealm.Domain/Enums/ActivityAction.cs
new file mode 100644
index 0000000..e40650a
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Enums/ActivityAction.cs
@@ -0,0 +1,21 @@
+namespace FantasyRealm.Domain.Enums
+{
+ ///
+ /// Represents the type of action logged in the activity log (MongoDB).
+ ///
+ public enum ActivityAction
+ {
+ CharacterApproved,
+ CharacterRejected,
+ CommentApproved,
+ CommentRejected,
+ ArticleCreated,
+ ArticleUpdated,
+ ArticleDeleted,
+ UserSuspended,
+ UserDeleted,
+ EmployeeCreated,
+ EmployeeSuspended,
+ EmployeeDeleted
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Enums/ArticleType.cs b/src/backend/src/FantasyRealm.Domain/Enums/ArticleType.cs
new file mode 100644
index 0000000..ca37cbd
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Enums/ArticleType.cs
@@ -0,0 +1,13 @@
+namespace FantasyRealm.Domain.Enums
+{
+ ///
+ /// Represents the type of customization article.
+ ///
+ public enum ArticleType
+ {
+ Clothing,
+ Armor,
+ Weapon,
+ Accessory
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Enums/CommentStatus.cs b/src/backend/src/FantasyRealm.Domain/Enums/CommentStatus.cs
new file mode 100644
index 0000000..fdabf09
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Enums/CommentStatus.cs
@@ -0,0 +1,11 @@
+namespace FantasyRealm.Domain.Enums
+{
+ ///
+ /// Represents the moderation status of a comment.
+ ///
+ public enum CommentStatus
+ {
+ Pending,
+ Approved
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/Enums/Gender.cs b/src/backend/src/FantasyRealm.Domain/Enums/Gender.cs
new file mode 100644
index 0000000..97dc860
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Domain/Enums/Gender.cs
@@ -0,0 +1,11 @@
+namespace FantasyRealm.Domain.Enums
+{
+ ///
+ /// Represents the gender of a character.
+ ///
+ public enum Gender
+ {
+ Male,
+ Female
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Domain/FantasyRealm.Domain.csproj b/src/backend/src/FantasyRealm.Domain/FantasyRealm.Domain.csproj
index fa71b7a..be3590d 100644
--- a/src/backend/src/FantasyRealm.Domain/FantasyRealm.Domain.csproj
+++ b/src/backend/src/FantasyRealm.Domain/FantasyRealm.Domain.csproj
@@ -6,4 +6,8 @@
enable
+
+
+
+
diff --git a/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs b/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..d84a3f6
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,45 @@
+using FantasyRealm.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using MongoDB.Driver;
+
+namespace FantasyRealm.Infrastructure
+{
+ ///
+ /// Extension methods for registering infrastructure services.
+ ///
+ public static class DependencyInjection
+ {
+ ///
+ /// Adds infrastructure services to the dependency injection container.
+ ///
+ /// The service collection.
+ /// The application configuration.
+ /// The service collection for chaining.
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ var postgreSqlConnectionString = configuration.GetConnectionString("PostgreSQL");
+ var mongoDbConnectionString = configuration.GetConnectionString("MongoDB");
+ var mongoDbDatabaseName = configuration["MongoDB:DatabaseName"] ?? "fantasyrealm";
+
+ if (!string.IsNullOrEmpty(postgreSqlConnectionString))
+ {
+ services.AddDbContext(options =>
+ options.UseNpgsql(postgreSqlConnectionString));
+ }
+
+ if (!string.IsNullOrEmpty(mongoDbConnectionString))
+ {
+ services.AddSingleton(_ => new MongoClient(mongoDbConnectionString));
+ services.AddSingleton(sp =>
+ {
+ var client = sp.GetRequiredService();
+ return new MongoDbContext(client, mongoDbDatabaseName);
+ });
+ }
+
+ return services;
+ }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/Conventions/SnakeCaseNamingConvention.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Conventions/SnakeCaseNamingConvention.cs
new file mode 100644
index 0000000..5f7ba50
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Conventions/SnakeCaseNamingConvention.cs
@@ -0,0 +1,73 @@
+using System.Text.RegularExpressions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+
+namespace FantasyRealm.Infrastructure.Persistence.Conventions
+{
+ ///
+ /// Applies snake_case naming convention to all database objects.
+ /// Converts PascalCase property names to snake_case column names.
+ ///
+ public static partial class SnakeCaseNamingConvention
+ {
+ public static void ApplySnakeCaseNamingConvention(this ModelBuilder modelBuilder)
+ {
+ foreach (var entity in modelBuilder.Model.GetEntityTypes())
+ {
+ ApplySnakeCaseToEntity(entity);
+ }
+ }
+
+ private static void ApplySnakeCaseToEntity(IMutableEntityType entity)
+ {
+ var tableName = ToSnakeCase(entity.GetTableName() ?? entity.ClrType.Name);
+ entity.SetTableName(tableName);
+
+ foreach (var property in entity.GetProperties())
+ {
+ var columnName = ToSnakeCase(property.Name);
+ property.SetColumnName(columnName);
+ }
+
+ foreach (var key in entity.GetKeys())
+ {
+ var keyName = key.GetName();
+ if (keyName != null)
+ {
+ key.SetName(ToSnakeCase(keyName));
+ }
+ }
+
+ foreach (var foreignKey in entity.GetForeignKeys())
+ {
+ var foreignKeyName = foreignKey.GetConstraintName();
+ if (foreignKeyName != null)
+ {
+ foreignKey.SetConstraintName(ToSnakeCase(foreignKeyName));
+ }
+ }
+
+ foreach (var index in entity.GetIndexes())
+ {
+ var indexName = index.GetDatabaseName();
+ if (indexName != null)
+ {
+ index.SetDatabaseName(ToSnakeCase(indexName));
+ }
+ }
+ }
+
+ private static string ToSnakeCase(string input)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return input;
+ }
+
+ return SnakeCaseRegex().Replace(input, "$1_$2").ToLowerInvariant();
+ }
+
+ [GeneratedRegex("([a-z0-9])([A-Z])")]
+ private static partial Regex SnakeCaseRegex();
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs
new file mode 100644
index 0000000..7275f1c
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs
@@ -0,0 +1,161 @@
+using FantasyRealm.Domain.Entities;
+using FantasyRealm.Domain.Enums;
+using FantasyRealm.Infrastructure.Persistence.Conventions;
+using Microsoft.EntityFrameworkCore;
+
+namespace FantasyRealm.Infrastructure.Persistence
+{
+ ///
+ /// Entity Framework Core database context for PostgreSQL.
+ ///
+ public class FantasyRealmDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Roles { get; set; } = null!;
+
+ public DbSet Users { get; set; } = null!;
+
+ public DbSet Characters { get; set; } = null!;
+
+ public DbSet Articles { get; set; } = null!;
+
+ public DbSet CharacterArticles { get; set; } = null!;
+
+ public DbSet Comments { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ ConfigureRole(modelBuilder);
+ ConfigureUser(modelBuilder);
+ ConfigureCharacter(modelBuilder);
+ ConfigureArticle(modelBuilder);
+ ConfigureCharacterArticle(modelBuilder);
+ ConfigureComment(modelBuilder);
+
+ modelBuilder.ApplySnakeCaseNamingConvention();
+ }
+
+ private static void ConfigureRole(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Label).HasMaxLength(50).IsRequired();
+ entity.HasIndex(e => e.Label).IsUnique();
+ });
+ }
+
+ private static void ConfigureUser(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Pseudo).HasMaxLength(50).IsRequired();
+ entity.Property(e => e.Email).HasMaxLength(100).IsRequired();
+ entity.Property(e => e.PasswordHash).HasMaxLength(255).IsRequired();
+ entity.Property(e => e.IsSuspended).HasDefaultValue(false);
+ entity.Property(e => e.MustChangePassword).HasDefaultValue(false);
+
+ entity.HasIndex(e => e.Pseudo).IsUnique();
+ entity.HasIndex(e => e.Email).IsUnique();
+
+ entity.HasOne(e => e.Role)
+ .WithMany(r => r.Users)
+ .HasForeignKey(e => e.RoleId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+ }
+
+ private static void ConfigureCharacter(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Name).HasMaxLength(50).IsRequired();
+ entity.Property(e => e.Gender)
+ .HasConversion()
+ .HasMaxLength(20)
+ .IsRequired();
+ entity.Property(e => e.SkinColor).HasMaxLength(7).IsRequired();
+ entity.Property(e => e.EyeColor).HasMaxLength(7).IsRequired();
+ entity.Property(e => e.HairColor).HasMaxLength(7).IsRequired();
+ entity.Property(e => e.EyeShape).HasMaxLength(50).IsRequired();
+ entity.Property(e => e.NoseShape).HasMaxLength(50).IsRequired();
+ entity.Property(e => e.MouthShape).HasMaxLength(50).IsRequired();
+ entity.Property(e => e.IsShared).HasDefaultValue(false);
+ entity.Property(e => e.IsAuthorized).HasDefaultValue(false);
+
+ entity.HasIndex(e => new { e.Name, e.UserId }).IsUnique();
+
+ entity.HasOne(e => e.User)
+ .WithMany(u => u.Characters)
+ .HasForeignKey(e => e.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+ }
+
+ private static void ConfigureArticle(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
+ entity.Property(e => e.Type)
+ .HasConversion()
+ .HasMaxLength(20)
+ .IsRequired();
+ entity.Property(e => e.IsActive).HasDefaultValue(true);
+
+ entity.HasIndex(e => e.Type);
+ entity.HasIndex(e => e.IsActive);
+ });
+ }
+
+ private static void ConfigureCharacterArticle(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.CharacterId, e.ArticleId });
+
+ entity.HasOne(e => e.Character)
+ .WithMany(c => c.CharacterArticles)
+ .HasForeignKey(e => e.CharacterId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(e => e.Article)
+ .WithMany(a => a.CharacterArticles)
+ .HasForeignKey(e => e.ArticleId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+ }
+
+ private static void ConfigureComment(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Rating).IsRequired();
+ entity.Property(e => e.Text).IsRequired();
+ entity.Property(e => e.Status)
+ .HasConversion()
+ .HasMaxLength(20)
+ .HasDefaultValue(CommentStatus.Pending);
+ entity.Property(e => e.CommentedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ entity.HasIndex(e => new { e.CharacterId, e.AuthorId }).IsUnique();
+ entity.HasIndex(e => e.Status);
+
+ entity.HasOne(e => e.Character)
+ .WithMany(c => c.Comments)
+ .HasForeignKey(e => e.CharacterId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(e => e.Author)
+ .WithMany(u => u.Comments)
+ .HasForeignKey(e => e.AuthorId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+ }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/MongoDbContext.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/MongoDbContext.cs
new file mode 100644
index 0000000..d331c23
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/MongoDbContext.cs
@@ -0,0 +1,16 @@
+using FantasyRealm.Domain.Entities;
+using MongoDB.Driver;
+
+namespace FantasyRealm.Infrastructure.Persistence
+{
+ ///
+ /// MongoDB context for activity logs storage.
+ ///
+ public class MongoDbContext(IMongoClient client, string databaseName)
+ {
+ private readonly IMongoDatabase _database = client.GetDatabase(databaseName);
+
+ public IMongoCollection ActivityLogs
+ => _database.GetCollection("activity_logs");
+ }
+}
diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/.gitkeep b/src/backend/tests/FantasyRealm.Tests.Unit/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/FantasyRealm.Tests.Unit.csproj b/src/backend/tests/FantasyRealm.Tests.Unit/FantasyRealm.Tests.Unit.csproj
index daddc5a..2c5eaf9 100644
--- a/src/backend/tests/FantasyRealm.Tests.Unit/FantasyRealm.Tests.Unit.csproj
+++ b/src/backend/tests/FantasyRealm.Tests.Unit/FantasyRealm.Tests.Unit.csproj
@@ -10,6 +10,8 @@
+
+
@@ -23,6 +25,7 @@
+
\ No newline at end of file
diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/Persistence/FantasyRealmDbContextTests.cs b/src/backend/tests/FantasyRealm.Tests.Unit/Persistence/FantasyRealmDbContextTests.cs
new file mode 100644
index 0000000..5d5c44e
--- /dev/null
+++ b/src/backend/tests/FantasyRealm.Tests.Unit/Persistence/FantasyRealmDbContextTests.cs
@@ -0,0 +1,253 @@
+using FantasyRealm.Domain.Entities;
+using FantasyRealm.Domain.Enums;
+using FantasyRealm.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace FantasyRealm.Tests.Unit.Persistence
+{
+ public class FantasyRealmDbContextTests
+ {
+ private static DbContextOptions CreateInMemoryOptions(string databaseName)
+ {
+ return new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: databaseName)
+ .Options;
+ }
+
+ [Fact]
+ public async Task CanAddAndRetrieveRole()
+ {
+ var options = CreateInMemoryOptions(nameof(CanAddAndRetrieveRole));
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var role = new Role { Label = "user" };
+ context.Roles.Add(role);
+ await context.SaveChangesAsync();
+ }
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var role = await context.Roles.FirstOrDefaultAsync();
+ Assert.NotNull(role);
+ Assert.Equal("user", role.Label);
+ }
+ }
+
+ [Fact]
+ public async Task CanAddUserWithRole()
+ {
+ var options = CreateInMemoryOptions(nameof(CanAddUserWithRole));
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var role = new Role { Label = "user" };
+ context.Roles.Add(role);
+ await context.SaveChangesAsync();
+
+ var user = new User
+ {
+ Pseudo = "player_one",
+ Email = "player@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user);
+ await context.SaveChangesAsync();
+ }
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var user = await context.Users.Include(u => u.Role).FirstOrDefaultAsync();
+ Assert.NotNull(user);
+ Assert.Equal("player_one", user.Pseudo);
+ Assert.Equal("user", user.Role.Label);
+ }
+ }
+
+ [Fact]
+ public async Task CanAddCharacterWithUser()
+ {
+ var options = CreateInMemoryOptions(nameof(CanAddCharacterWithUser));
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var role = new Role { Label = "user" };
+ context.Roles.Add(role);
+ await context.SaveChangesAsync();
+
+ var user = new User
+ {
+ Pseudo = "player_one",
+ Email = "player@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "Thorin",
+ Gender = Gender.Male,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ UserId = user.Id
+ };
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+ }
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var character = await context.Characters.Include(c => c.User).FirstOrDefaultAsync();
+ Assert.NotNull(character);
+ Assert.Equal("Thorin", character.Name);
+ Assert.Equal(Gender.Male, character.Gender);
+ Assert.Equal("player_one", character.User.Pseudo);
+ }
+ }
+
+ [Fact]
+ public async Task CanEquipArticleToCharacter()
+ {
+ var options = CreateInMemoryOptions(nameof(CanEquipArticleToCharacter));
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var role = new Role { Label = "user" };
+ context.Roles.Add(role);
+ await context.SaveChangesAsync();
+
+ var user = new User
+ {
+ Pseudo = "player_one",
+ Email = "player@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user);
+
+ var article = new Article
+ {
+ Name = "Iron Sword",
+ Type = ArticleType.Weapon
+ };
+ context.Articles.Add(article);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "Thorin",
+ Gender = Gender.Male,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ UserId = user.Id
+ };
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+
+ var characterArticle = new CharacterArticle
+ {
+ CharacterId = character.Id,
+ ArticleId = article.Id
+ };
+ context.CharacterArticles.Add(characterArticle);
+ await context.SaveChangesAsync();
+ }
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var character = await context.Characters
+ .Include(c => c.CharacterArticles)
+ .ThenInclude(ca => ca.Article)
+ .FirstOrDefaultAsync();
+
+ Assert.NotNull(character);
+ Assert.Single(character.CharacterArticles);
+ Assert.Equal("Iron Sword", character.CharacterArticles.First().Article.Name);
+ }
+ }
+
+ [Fact]
+ public async Task CanAddCommentToCharacter()
+ {
+ var options = CreateInMemoryOptions(nameof(CanAddCommentToCharacter));
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var role = new Role { Label = "user" };
+ context.Roles.Add(role);
+ await context.SaveChangesAsync();
+
+ var owner = new User
+ {
+ Pseudo = "owner",
+ Email = "owner@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ var commenter = new User
+ {
+ Pseudo = "commenter",
+ Email = "commenter@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.AddRange(owner, commenter);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "Thorin",
+ Gender = Gender.Male,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ IsShared = true,
+ IsAuthorized = true,
+ UserId = owner.Id
+ };
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+
+ var comment = new Comment
+ {
+ Rating = 5,
+ Text = "Amazing character!",
+ Status = CommentStatus.Approved,
+ CommentedAt = DateTime.UtcNow,
+ CharacterId = character.Id,
+ AuthorId = commenter.Id
+ };
+ context.Comments.Add(comment);
+ await context.SaveChangesAsync();
+ }
+
+ using (var context = new FantasyRealmDbContext(options))
+ {
+ var comment = await context.Comments
+ .Include(c => c.Character)
+ .Include(c => c.Author)
+ .FirstOrDefaultAsync();
+
+ Assert.NotNull(comment);
+ Assert.Equal(5, comment.Rating);
+ Assert.Equal("Thorin", comment.Character.Name);
+ Assert.Equal("commenter", comment.Author.Pseudo);
+ }
+ }
+
+ }
+}