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); + } + } + + } +}