Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 45 additions & 42 deletions database/sql/001_create_tables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,99 +4,102 @@
-- ============================================================================

-- ============================================================================
-- TABLE: role
-- TABLE: roles
-- User roles for authorization (user, employee, admin)
-- ============================================================================
CREATE TABLE role (
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
label VARCHAR(50) NOT NULL UNIQUE
);

-- Seed default roles
INSERT INTO roles (label) VALUES ('User'), ('Employee'), ('Admin');

-- ============================================================================
-- TABLE: user
-- TABLE: users
-- Registered users of the application
-- ============================================================================
CREATE TABLE "user" (
CREATE TABLE users (
id SERIAL PRIMARY KEY,
pseudo VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(50) NOT NULL,
email VARCHAR(255) 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)
role_id INTEGER NOT NULL REFERENCES roles(id)
);

CREATE INDEX idx_user_email ON "user"(email);
CREATE INDEX idx_user_pseudo ON "user"(pseudo);
CREATE INDEX idx_user_role_id ON "user"(role_id);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_pseudo ON users(pseudo);
CREATE INDEX idx_users_role_id ON users(role_id);

-- ============================================================================
-- TABLE: character
-- TABLE: characters
-- Player characters created by users
-- ============================================================================
CREATE TABLE character (
CREATE TABLE characters (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
gender VARCHAR(50) NOT NULL,
skin_color VARCHAR(50) NOT NULL,
eye_color VARCHAR(50) NOT NULL,
hair_color VARCHAR(50) NOT NULL,
gender VARCHAR(20) NOT NULL,
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,
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
image BYTEA,
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

CONSTRAINT uq_character_name_per_user UNIQUE (name, user_id)
CONSTRAINT uq_characters_name_per_user UNIQUE (name, user_id)
);

CREATE INDEX idx_character_user_id ON character(user_id);
CREATE INDEX idx_character_gallery ON character(is_shared, is_approved) WHERE is_shared = TRUE AND is_approved = TRUE;
CREATE INDEX idx_characters_user_id ON characters(user_id);
CREATE INDEX idx_characters_gallery ON characters(is_shared, is_authorized) WHERE is_shared = TRUE AND is_authorized = TRUE;

-- ============================================================================
-- TABLE: article
-- TABLE: articles
-- Customization items (clothing, armor, weapons, accessories)
-- ============================================================================
CREATE TABLE article (
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
image BYTEA,
type VARCHAR(50) NOT NULL
type VARCHAR(20) NOT NULL
);

CREATE INDEX idx_article_type ON article(type);
CREATE INDEX idx_article_active_type ON article(type) WHERE is_active = TRUE;
CREATE INDEX idx_articles_type ON articles(type);
CREATE INDEX idx_articles_active_type ON articles(type) WHERE is_active = TRUE;

-- ============================================================================
-- TABLE: character_article
-- TABLE: character_articles
-- 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,
CREATE TABLE character_articles (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,

PRIMARY KEY (character_id, article_id)
);

-- ============================================================================
-- TABLE: comment
-- TABLE: comments
-- User reviews on shared characters
-- ============================================================================
CREATE TABLE comment (
CREATE TABLE comments (
id SERIAL PRIMARY KEY,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
content VARCHAR(50) NOT NULL,
comment_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
character_id INTEGER NOT NULL REFERENCES character(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
text TEXT NOT NULL,
commented_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

CONSTRAINT uq_one_comment_per_user_per_character UNIQUE (character_id, author_id)
CONSTRAINT uq_comments_one_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_pending ON comment(character_id) WHERE status = 'pending';
CREATE INDEX idx_comments_character_id ON comments(character_id);
CREATE INDEX idx_comments_author_id ON comments(author_id);
CREATE INDEX idx_comments_pending ON comments(character_id) WHERE status = 'pending';
22 changes: 22 additions & 0 deletions database/sql/002_add_user_audit_columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration: Add audit columns to users table
-- ============================================================================

-- Add created_at and updated_at columns for audit trail
ALTER TABLE users
ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;

-- Create trigger to auto-update updated_at on row modification
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
2 changes: 2 additions & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-fantasyrealm}
volumes:
- postgres_data:/var/lib/postgresql/data
- ../database/sql:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fantasyrealm}"]
interval: 5s
Expand Down Expand Up @@ -61,6 +62,7 @@ services:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__PostgreSQL: "Host=postgres;Port=5432;Database=${POSTGRES_DB:-fantasyrealm};Username=${POSTGRES_USER:-fantasyrealm};Password=${POSTGRES_PASSWORD}"
ConnectionStrings__MongoDB: "mongodb://${MONGO_USER:-fantasyrealm}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB:-fantasyrealm_logs}?authSource=admin"
Email__Password: ${EMAIL_PASSWORD}
depends_on:
postgres:
condition: service_healthy
Expand Down
46 changes: 46 additions & 0 deletions src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using FantasyRealm.Application.DTOs;
using FantasyRealm.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace FantasyRealm.Api.Controllers
{
/// <summary>
/// Controller for authentication-related endpoints.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
private readonly IAuthService _authService;

public AuthController(IAuthService authService)
{
_authService = authService;
}

/// <summary>
/// Registers a new user account.
/// </summary>
/// <param name="request">The registration details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created user information.</returns>
/// <response code="201">User successfully registered.</response>
/// <response code="400">Invalid request data or password validation failed.</response>
/// <response code="409">Email or pseudo already exists.</response>
[HttpPost("register")]
[ProducesResponseType(typeof(RegisterResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var result = await _authService.RegisterAsync(request, cancellationToken);

if (result.IsFailure)
{
return StatusCode(result.ErrorCode ?? 400, new { message = result.Error });
}

return CreatedAtAction(nameof(Register), new { id = result.Value!.Id }, result.Value);
}
}
}
3 changes: 0 additions & 3 deletions src/backend/src/FantasyRealm.Api/FantasyRealm.Api.http
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
@FantasyRealm.Api_HostAddress = http://localhost:5071

GET {{FantasyRealm.Api_HostAddress}}/weatherforecast/
Accept: application/json

###
12 changes: 8 additions & 4 deletions src/backend/src/FantasyRealm.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
using FantasyRealm.Infrastructure;

namespace FantasyRealm.Api
{
public static class Program
/// <summary>
/// Application entry point.
/// </summary>
public partial class Program
{
private static void Main(string[] args)
{
Expand All @@ -23,9 +28,8 @@ private static void Main(string[] args)
});
});

// TODO: Add JWT Authentication (FRO-1)
// TODO: Add Application services (FRO-15+)
// TODO: Add Infrastructure services (FRO-17)
// Infrastructure services (Database, Email, Auth)
builder.Services.AddInfrastructure(builder.Configuration);

var app = builder.Build();

Expand Down
43 changes: 43 additions & 0 deletions src/backend/src/FantasyRealm.Application/Common/Result.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace FantasyRealm.Application.Common
{
/// <summary>
/// Represents the result of an operation that can either succeed with a value or fail with an error.
/// </summary>
/// <typeparam name="T">The type of the value returned on success.</typeparam>
public sealed class Result<T>
{
private Result(T? value, string? error, int? errorCode, bool isSuccess)
{
Value = value;
Error = error;
ErrorCode = errorCode;
IsSuccess = isSuccess;
}

public bool IsSuccess { get; }

public bool IsFailure => !IsSuccess;

public T? Value { get; }

public string? Error { get; }

public int? ErrorCode { get; }

/// <summary>
/// Creates a successful result with the specified value.
/// </summary>
public static Result<T> Success(T value)
{
return new Result<T>(value, null, null, true);
}

/// <summary>
/// Creates a failed result with the specified error message and HTTP status code.
/// </summary>
public static Result<T> Failure(string error, int errorCode = 400)
{
return new Result<T>(default, error, errorCode, false);
}
}
}
25 changes: 25 additions & 0 deletions src/backend/src/FantasyRealm.Application/DTOs/RegisterRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;

namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Request payload for user registration.
/// </summary>
public sealed record RegisterRequest(
[Required(ErrorMessage = "L'email est requis.")]
[EmailAddress(ErrorMessage = "Le format de l'email est invalide.")]
[MaxLength(255, ErrorMessage = "L'email ne peut pas dépasser 255 caractères.")]
string Email,

[Required(ErrorMessage = "Le pseudo est requis.")]
[MinLength(3, ErrorMessage = "Le pseudo doit contenir au moins 3 caractères.")]
[MaxLength(30, ErrorMessage = "Le pseudo ne peut pas dépasser 30 caractères.")]
string Pseudo,

[Required(ErrorMessage = "Le mot de passe est requis.")]
string Password,

[Required(ErrorMessage = "La confirmation du mot de passe est requise.")]
string ConfirmPassword
);
}
13 changes: 13 additions & 0 deletions src/backend/src/FantasyRealm.Application/DTOs/RegisterResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Response payload after successful user registration.
/// </summary>
public sealed record RegisterResponse(
int Id,
string Email,
string Pseudo,
string Role,
DateTime CreatedAt
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>

<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using FantasyRealm.Application.Common;
using FantasyRealm.Application.DTOs;

namespace FantasyRealm.Application.Interfaces
{
/// <summary>
/// Service interface for authentication operations.
/// </summary>
public interface IAuthService
{
/// <summary>
/// Registers a new user account.
/// </summary>
/// <param name="request">The registration request containing user details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result containing the registered user info or an error.</returns>
Task<Result<RegisterResponse>> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace FantasyRealm.Application.Interfaces
{
/// <summary>
/// Provides password hashing and verification functionality.
/// </summary>
public interface IPasswordHasher
{
/// <summary>
/// Hashes a plain text password using a secure algorithm.
/// </summary>
/// <param name="password">The plain text password to hash.</param>
/// <returns>The hashed password with embedded salt.</returns>
string Hash(string password);

/// <summary>
/// Verifies a plain text password against a hashed password.
/// </summary>
/// <param name="password">The plain text password to verify.</param>
/// <param name="hash">The hashed password to compare against.</param>
/// <returns>True if the password matches the hash; otherwise, false.</returns>
bool Verify(string password, string hash);
}
}
Loading