Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1,428 changes: 1,428 additions & 0 deletions backend/AddServiceEvaluation.sql
Comment thread
ahmed12348 marked this conversation as resolved.
Outdated

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions backend/src/CCE.Api.Common/Localization/Resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ VALIDATION_REQUIRED_FIELD:
ar: "هذا الحقل مطلوب"
en: "This field is required"

REQUIRED_FIELD:
ar: "هذا الحقل مطلوب"
en: "This field is required"

VALIDATION_INVALID_EMAIL:
ar: "البريد الإلكتروني غير صالح"
en: "Invalid email format"
Expand All @@ -174,6 +178,10 @@ VALIDATION_MAX_LENGTH:
ar: "القيمة طويلة جدًا"
en: "Value is too long"

MAX_LENGTH:
ar: "القيمة طويلة جدًا"
en: "Value is too long"

VALIDATION_INVALID_FORMAT:
ar: "التنسيق غير صالح"
en: "Invalid format"
Expand All @@ -182,6 +190,10 @@ VALIDATION_INVALID_ENUM:
ar: "القيمة المحددة غير صالحة"
en: "Selected value is invalid"

INVALID_ENUM:
ar: "القيمة المحددة غير صالحة"
en: "Selected value is invalid"

# ─── Identity Bare Keys (errors) ───

USER_NOT_FOUND:
Expand Down Expand Up @@ -456,3 +468,11 @@ NOTIFICATION_TEMPLATE_CREATED:
NOTIFICATION_TEMPLATE_UPDATED:
ar: "تم تحديث قالب الإشعار بنجاح"
en: "Notification template updated successfully"

EVALUATION_NOT_FOUND:
ar: "التقييم غير موجود"
en: "Evaluation not found"

EVALUATION_SUBMITTED:
ar: "تم تقديم التقييم بنجاح"
en: "Evaluation submitted successfully"
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ private static async Task WriteValidationResultAsync(HttpContext ctx, Validation

var fieldErrors = ex.Errors.Select(e =>
{
var domainKey = e.ErrorMessage;
var domainKey = e.ErrorCode;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explain this change

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those were optional fixes I added to improve the ERR900 validation error display.

var valCode = SystemCodeMap.ToSystemCode(domainKey);
var valMsg = l?.GetString(domainKey) ?? domainKey;
if (valMsg == domainKey) valMsg = e.ErrorMessage;
return new
{
field = ToCamelCase(e.PropertyName),
Expand Down
51 changes: 51 additions & 0 deletions backend/src/CCE.Api.External/Endpoints/EvaluationEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Evaluation.Commands.SubmitEvaluation;
using CCE.Domain.Evaluation;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace CCE.Api.External.Endpoints;

public static class EvaluationEndpoints
{
public static IEndpointRouteBuilder MapEvaluationEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/evaluations").WithTags("Evaluations");

// POST /api/evaluations — public submit (visitors & authenticated users)
group.MapPost("", async (
SubmitEvaluationRequest body,
IMediator mediator,
CancellationToken ct) =>
{
if (!Enum.IsDefined(typeof(EvaluationRating), body.OverallSatisfaction) || body.OverallSatisfaction == 0)
return Results.BadRequest(new { error = "OverallSatisfaction must be 1-5 (1=Excellent, 2=Satisfied, 3=Neutral, 4=Dissatisfied, 5=Poor)." });
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// the endpoint currently contains business/input validation rules
// and repeats the same pattern 3 times.

// moving this to validator
would keep the endpoint thinner and centralize validation behavior.


if (!Enum.IsDefined(typeof(EvaluationRating), body.EaseOfUse) || body.EaseOfUse == 0)
return Results.BadRequest(new { error = "EaseOfUse must be 1-5 (1=Excellent, 2=Satisfied, 3=Neutral, 4=Dissatisfied, 5=Poor)." });

if (!Enum.IsDefined(typeof(EvaluationRating), body.ContentSuitability) || body.ContentSuitability == 0)
return Results.BadRequest(new { error = "ContentSuitability must be 1-5 (1=Excellent, 2=Satisfied, 3=Neutral, 4=Dissatisfied, 5=Poor)." });

var cmd = new SubmitEvaluationCommand(
(EvaluationRating)body.OverallSatisfaction,
(EvaluationRating)body.EaseOfUse,
(EvaluationRating)body.ContentSuitability,
body.Feedback);
var result = await mediator.Send(cmd, ct).ConfigureAwait(false);
return result.ToHttpResult(StatusCodes.Status201Created);
})
.AllowAnonymous()
.WithName("SubmitEvaluation");

return app;
}
}

public sealed record SubmitEvaluationRequest(
int OverallSatisfaction,
int EaseOfUse,
int ContentSuitability,
string Feedback);
1 change: 1 addition & 0 deletions backend/src/CCE.Api.External/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
app.MapAssistantEndpoints();
app.MapKapsarcEndpoints();
app.MapSurveysEndpoints();
app.MapEvaluationEndpoints();
app.MapHomepageSettingsPublicEndpoints();
app.MapAboutSettingsPublicEndpoints();
app.MapPoliciesSettingsPublicEndpoints();
Expand Down
43 changes: 43 additions & 0 deletions backend/src/CCE.Api.Internal/Endpoints/EvaluationEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Evaluation.Queries.GetAllEvaluations;
using CCE.Application.Evaluation.Queries.GetEvaluationById;
using CCE.Domain;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace CCE.Api.Internal.Endpoints;

public static class EvaluationEndpoints
{
public static IEndpointRouteBuilder MapEvaluationEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/evaluations").WithTags("Evaluations");

// GET /api/admin/evaluations — list all (admin only)
group.MapGet("", async (
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(new GetAllEvaluationsQuery(), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.Survey_ReadAll)
.WithName("GetAllEvaluations");

// GET /api/admin/evaluations/{id} — get by id (admin only)
group.MapGet("{id:guid}", async (
System.Guid id,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(new GetEvaluationByIdQuery(id), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.Survey_ReadAll)
.WithName("GetEvaluationById");

return app;
}
}
1 change: 1 addition & 0 deletions backend/src/CCE.Api.Internal/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
app.MapAboutSettingsEndpoints();
app.MapPoliciesSettingsEndpoints();
app.MapMediaEndpoints();
app.MapEvaluationEndpoints();

// Sub-11d follow-up — dev sign-in shim. Mounts /dev/sign-in,
// /dev/sign-out, /dev/whoami when Auth:DevMode=true. Production
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ public async Task<TResponse> Handle(
{
var fieldErrors = failures.Select(f =>
{
var domainKey = f.ErrorMessage;
var domainKey = f.ErrorCode;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explain the change

var valCode = SystemCodeMap.ToSystemCode(domainKey);
var msg = _l.GetString(domainKey);
if (msg == domainKey) msg = f.ErrorMessage;
return new FieldError(
ToCamelCase(f.PropertyName),
valCode,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/CCE.Application/Common/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ public Error RegistrationFailed(IDictionary<string, string[]>? details = null)
public Error PostNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.POST_NOT_FOUND}");
public Error ReplyNotFound() => NotFound($"COMMUNITY_{ApplicationErrors.Community.REPLY_NOT_FOUND}");

// ─── Convenience: Evaluation domain ───
public Error EvaluationNotFound() => NotFound($"EVALUATION_{ApplicationErrors.Evaluation.EVALUATION_NOT_FOUND}");

// ─── Convenience: Country domain ───
public Error CountryNotFound() => NotFound($"COUNTRY_{ApplicationErrors.Country.COUNTRY_NOT_FOUND}");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using CCE.Domain.Audit;
using CCE.Domain.Community;
using CCE.Domain.Content;
using CCE.Domain.Evaluation;
using CCE.Domain.Identity;
using CCE.Domain.InteractiveCity;
using CCE.Domain.KnowledgeMaps;
Expand Down Expand Up @@ -75,6 +76,9 @@ public interface ICceDbContext
IQueryable<OtpVerification> OtpVerifications { get; }
IQueryable<UserVerification> UserVerifications { get; }

// ─── Evaluation ───
IQueryable<ServiceEvaluation> ServiceEvaluations { get; }

// ─── Media ───
IQueryable<MediaFile> MediaFiles { get; }

Expand Down
6 changes: 6 additions & 0 deletions backend/src/CCE.Application/Errors/ApplicationErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ public static class InteractiveCity
public const string TECHNOLOGY_NOT_FOUND = "TECHNOLOGY_NOT_FOUND";
}

public static class Evaluation
{
public const string EVALUATION_NOT_FOUND = "EVALUATION_NOT_FOUND";
public const string EVALUATION_SUBMITTED = "EVALUATION_SUBMITTED";
}

public static class Validation
{
public const string REQUIRED_FIELD = "REQUIRED_FIELD";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using CCE.Application.Common;
using CCE.Domain.Evaluation;
using MediatR;

namespace CCE.Application.Evaluation.Commands.SubmitEvaluation;

public sealed record SubmitEvaluationCommand(
EvaluationRating OverallSatisfaction,
EvaluationRating EaseOfUse,
EvaluationRating ContentSuitability,
string Feedback) : IRequest<Response<VoidData>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using CCE.Application.Common;
using CCE.Application.Common.Interfaces;
using CCE.Application.Errors;
using CCE.Application.Messages;
using CCE.Domain.Common;
using DomainEvaluation = CCE.Domain.Evaluation.ServiceEvaluation;
using MediatR;

namespace CCE.Application.Evaluation.Commands.SubmitEvaluation;

public sealed class SubmitEvaluationCommandHandler
: IRequestHandler<SubmitEvaluationCommand, Response<VoidData>>
{
private readonly IEvaluationRepository _repository;
private readonly ICurrentUserAccessor _currentUser;
private readonly ISystemClock _clock;
private readonly MessageFactory _messageFactory;

public SubmitEvaluationCommandHandler(
IEvaluationRepository repository,
ICurrentUserAccessor currentUser,
ISystemClock clock,
MessageFactory messageFactory)
{
_repository = repository;
_currentUser = currentUser;
_clock = clock;
_messageFactory = messageFactory;
}

public async Task<Response<VoidData>> Handle(
SubmitEvaluationCommand request,
CancellationToken cancellationToken)
{
var userId = _currentUser.GetUserId();

var evaluation = DomainEvaluation.Submit(
request.OverallSatisfaction,
request.EaseOfUse,
request.ContentSuitability,
request.Feedback,
userId,
_clock);

await _repository.AddAsync(evaluation, cancellationToken).ConfigureAwait(false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i mentioned in faq pr same comment here to use the unit of work to save the transaction


return _messageFactory.Ok(ApplicationErrors.Evaluation.EVALUATION_SUBMITTED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using CCE.Domain.Evaluation;
using FluentValidation;

namespace CCE.Application.Evaluation.Commands.SubmitEvaluation;

public sealed class SubmitEvaluationCommandValidator : AbstractValidator<SubmitEvaluationCommand>
{
public SubmitEvaluationCommandValidator()
{
RuleFor(x => x.OverallSatisfaction)
.IsInEnum().WithErrorCode("INVALID_ENUM")
.NotEqual(EvaluationRating.None).WithErrorCode("REQUIRED_FIELD");
RuleFor(x => x.EaseOfUse)
.IsInEnum().WithErrorCode("INVALID_ENUM")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// we can keep only the version with error codes
to avoid duplicate validation execution and improve readability.

.NotEqual(EvaluationRating.None).WithErrorCode("REQUIRED_FIELD");
RuleFor(x => x.ContentSuitability)
.IsInEnum().WithErrorCode("INVALID_ENUM")
.NotEqual(EvaluationRating.None).WithErrorCode("REQUIRED_FIELD");
RuleFor(x => x.Feedback)
.NotEmpty().WithErrorCode("REQUIRED_FIELD")
.MaximumLength(500).WithErrorCode("MAX_LENGTH");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using CCE.Domain.Evaluation;

namespace CCE.Application.Evaluation.DTOs;

public sealed record ServiceEvaluationDto(
System.Guid Id,
EvaluationRating OverallSatisfaction,
EvaluationRating EaseOfUse,
EvaluationRating ContentSuitability,
string Feedback,
System.Guid? UserId,
System.DateTimeOffset CreatedOn,
System.Guid CreatedById);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CCE.Domain.Evaluation;

namespace CCE.Application.Evaluation;

public interface IEvaluationRepository
{
Task AddAsync(ServiceEvaluation evaluation, CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using CCE.Application.Common;
using CCE.Application.Evaluation.DTOs;
using MediatR;

namespace CCE.Application.Evaluation.Queries.GetAllEvaluations;

public sealed record GetAllEvaluationsQuery : IRequest<Response<List<ServiceEvaluationDto>>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using CCE.Application.Common;
using CCE.Application.Common.Interfaces;
using CCE.Application.Evaluation.DTOs;
using CCE.Application.Messages;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace CCE.Application.Evaluation.Queries.GetAllEvaluations;

public sealed class GetAllEvaluationsQueryHandler
: IRequestHandler<GetAllEvaluationsQuery, Response<List<ServiceEvaluationDto>>>
{
private readonly ICceDbContext _db;
private readonly MessageFactory _msg;

public GetAllEvaluationsQueryHandler(ICceDbContext db, MessageFactory msg)
{
_db = db;
_msg = msg;
}

public async Task<Response<List<ServiceEvaluationDto>>> Handle(
GetAllEvaluationsQuery request,
CancellationToken cancellationToken)
{
var evaluations = await _db.ServiceEvaluations
.OrderByDescending(e => e.CreatedOn)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add pagenation to this list

.ToListAsync(cancellationToken)
.ConfigureAwait(false);

var dtos = evaluations.Select(e => new ServiceEvaluationDto(
e.Id,
e.OverallSatisfaction,
e.EaseOfUse,
e.ContentSuitability,
e.Feedback,
e.UserId,
e.CreatedOn,
e.CreatedById)).ToList();

return _msg.Ok(dtos, "ITEMS_LISTED");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using CCE.Application.Common;
using CCE.Application.Evaluation.DTOs;
using MediatR;

namespace CCE.Application.Evaluation.Queries.GetEvaluationById;

public sealed record GetEvaluationByIdQuery(System.Guid Id) : IRequest<Response<ServiceEvaluationDto>>;
Loading