using System.Data.Common; using System.Globalization; using System.Text.Json.Nodes; using Eis.Infrastructure.Data; namespace Eis.Infrastructure.Candidate; internal sealed record SchoolClassSelection( string SchoolId, string SchoolName, string ClassId, string ClassName); internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory) { public async Task FindSchoolClassAsync( string schoolId, string classId, CancellationToken cancellationToken) { const string sql = """ SELECT school.id AS school_id, school.name AS school_name, class.id AS class_id, class.name AS class_name FROM schools school JOIN school_classes class ON class.school_id = school.id WHERE school.id = @schoolId AND class.id = @classId AND school.active = 1 AND school.is_source_school = 1 AND class.active = 1 LIMIT 1 """; await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var command = CreateCommand(connection, sql, [ new("@schoolId", schoolId), new("@classId", classId) ]); await using var reader = await command.ExecuteReaderAsync(cancellationToken); return await reader.ReadAsync(cancellationToken) ? new SchoolClassSelection( ReadString(reader, "school_id"), ReadString(reader, "school_name"), ReadString(reader, "class_id"), ReadString(reader, "class_name")) : null; } public async Task IdNumberExistsAsync( string idNumber, string excludedProfileId, CancellationToken cancellationToken) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var command = CreateCommand(connection, "SELECT COUNT(*) FROM candidate_profiles WHERE id <> @profileId AND id_number = @idNumber", [new("@profileId", excludedProfileId), new("@idNumber", idNumber)]); return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0; } public async Task RegistrationExistsAsync( string userId, string examId, CancellationToken cancellationToken) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var command = CreateCommand(connection, "SELECT COUNT(*) FROM registrations WHERE user_id = @userId AND exam_id = @examId", [new("@userId", userId), new("@examId", examId)]); return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0; } public async Task GetActiveNumberRuleIdAsync(CancellationToken cancellationToken) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var command = CreateCommand(connection, "SELECT id FROM number_rules WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1", []); var value = await command.ExecuteScalarAsync(cancellationToken); return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture); } public async Task UpdateProfileAsync( JsonObject profile, string displayName, CandidateWorkflowInstance? instance, CandidateWorkflowAction? action, CancellationToken cancellationToken) { const string sql = """ UPDATE candidate_profiles SET name = @name, gender = @gender, id_number = @idNumber, phone = @phone, email = @email, school = @school, grade = @grade, province_code = @provinceCode, province_name = @provinceName, city_code = @cityCode, city_name = @cityName, district_code = @districtCode, district_name = @districtName, address = @address, school_id = @schoolId, class_id = @classId, emergency_contact = @emergencyContact, emergency_phone = @emergencyPhone, status = @status, review_note = @reviewNote, native_place = @nativePlace, birth_date = @birthDate, ethnicity = @ethnicity, postal_code = @postalCode, guardian_name = @guardianName, guardian_phone = @guardianPhone, specialty_category = @specialtyCategory, specialty_type = @specialtyType, specialty_types = @specialtyTypes, specialty_certificate = @specialtyCertificate, policy_eligibility = @policyEligibility, profile_completed = @profileCompleted, reviewed_at = @reviewedAt, reviewer_id = @reviewerId, updated_at = @updatedAt WHERE id = @id """; var parameters = new QueryParameter[] { new("@name", Text(profile, "name")), new("@gender", Optional(Text(profile, "gender"))), new("@idNumber", Text(profile, "idNumber")), new("@phone", Text(profile, "phone")), new("@email", Optional(Text(profile, "email"))), new("@school", Optional(Text(profile, "school"))), new("@grade", Optional(Text(profile, "grade"))), new("@provinceCode", Optional(Text(profile, "provinceCode"))), new("@provinceName", Optional(Text(profile, "provinceName"))), new("@cityCode", Optional(Text(profile, "cityCode"))), new("@cityName", Optional(Text(profile, "cityName"))), new("@districtCode", Optional(Text(profile, "districtCode"))), new("@districtName", Optional(Text(profile, "districtName"))), new("@address", Optional(Text(profile, "address"))), new("@schoolId", Optional(Text(profile, "schoolId"))), new("@classId", Optional(Text(profile, "classId"))), new("@emergencyContact", Optional(Text(profile, "emergencyContact"))), new("@emergencyPhone", Optional(Text(profile, "emergencyPhone"))), new("@status", Text(profile, "status")), new("@reviewNote", Optional(Text(profile, "reviewNote"))), new("@nativePlace", Optional(Text(profile, "nativePlace"))), new("@birthDate", Optional(Text(profile, "birthDate"))), new("@ethnicity", Optional(Text(profile, "ethnicity"))), new("@postalCode", Optional(Text(profile, "postalCode"))), new("@guardianName", Optional(Text(profile, "guardianName"))), new("@guardianPhone", Optional(Text(profile, "guardianPhone"))), new("@specialtyCategory", Optional(Text(profile, "specialtyCategory"))), new("@specialtyType", Optional(Text(profile, "specialtyType"))), new("@specialtyTypes", profile["specialtyTypes"]?.ToJsonString() ?? "[]"), new("@specialtyCertificate", Optional(Text(profile, "specialtyCertificate"))), new("@policyEligibility", Optional(Text(profile, "policyEligibility"))), new("@profileCompleted", Boolean(profile, "profileCompleted") ? 1 : 0), new("@reviewedAt", Optional(Text(profile, "reviewedAt"))), new("@reviewerId", Optional(Text(profile, "reviewerId"))), new("@updatedAt", Text(profile, "updatedAt")), new("@id", Text(profile, "id")) }; await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); try { await ExecuteAsync(connection, transaction, sql, parameters, cancellationToken); await ExecuteAsync(connection, transaction, "UPDATE users SET display_name = @displayName WHERE id = @userId", [new("@displayName", displayName), new("@userId", Text(profile, "userId"))], cancellationToken); if (instance is not null && action is not null) { await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken); } await transaction.CommitAsync(cancellationToken); } catch { await transaction.RollbackAsync(cancellationToken); throw; } } public async Task CreateRegistrationAsync( CandidateRegistration registration, CandidateWorkflowInstance? instance, CandidateWorkflowAction? action, CancellationToken cancellationToken) { const string sql = """ INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score ) VALUES ( @id, @userId, @examId, @status, @paymentStatus, @paidAt, @paidBy, @createdAt, @reviewedAt, @reviewNote, @registrationNumber, @numberRuleId, @featureScore ) """; await using var connection = await connectionFactory.OpenAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); try { await ExecuteAsync(connection, transaction, sql, [ new("@id", registration.Id), new("@userId", registration.UserId), new("@examId", registration.ExamId), new("@status", registration.Status), new("@paymentStatus", registration.PaymentStatus), new("@paidAt", registration.PaidAt), new("@paidBy", registration.PaidBy), new("@createdAt", registration.CreatedAt), new("@reviewedAt", registration.ReviewedAt), new("@reviewNote", Optional(registration.ReviewNote)), new("@registrationNumber", Optional(registration.RegistrationNumber)), new("@numberRuleId", registration.NumberRuleId), new("@featureScore", registration.FeatureScore) ], cancellationToken); foreach (var subjectId in registration.SubjectIds) { await ExecuteAsync(connection, transaction, "INSERT INTO registration_subjects (registration_id, subject_id) VALUES (@registrationId, @subjectId)", [new("@registrationId", registration.Id), new("@subjectId", subjectId)], cancellationToken); } if (instance is not null && action is not null) { await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken); } await transaction.CommitAsync(cancellationToken); } catch { await transaction.RollbackAsync(cancellationToken); throw; } } private static async Task InsertWorkflowAsync( DbConnection connection, DbTransaction transaction, CandidateWorkflowInstance instance, CandidateWorkflowAction action, CancellationToken cancellationToken) { const string instanceSql = """ INSERT INTO workflow_instances ( id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at ) VALUES ( @id, @workflowId, @businessType, @businessId, @status, @currentStep, @assigneeId, @createdAt, @completedAt ) """; await ExecuteAsync(connection, transaction, instanceSql, [ new("@id", instance.Id), new("@workflowId", instance.WorkflowId), new("@businessType", instance.BusinessType), new("@businessId", instance.BusinessId), new("@status", instance.Status), new("@currentStep", instance.CurrentStep), new("@assigneeId", instance.AssigneeId), new("@createdAt", instance.CreatedAt), new("@completedAt", instance.CompletedAt) ], cancellationToken); const string actionSql = """ INSERT INTO workflow_actions ( id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at ) VALUES ( @id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt ) """; await ExecuteAsync(connection, transaction, actionSql, [ new("@id", action.Id), new("@instanceId", action.InstanceId), new("@actorId", action.ActorId), new("@action", action.Action), new("@note", Optional(action.Note)), new("@fromAssigneeId", action.FromAssigneeId), new("@toAssigneeId", action.ToAssigneeId), new("@createdAt", action.CreatedAt) ], cancellationToken); } private static async Task ExecuteAsync( DbConnection connection, DbTransaction transaction, string sql, IReadOnlyList parameters, CancellationToken cancellationToken) { await using var command = CreateCommand(connection, sql, parameters, transaction); await command.ExecuteNonQueryAsync(cancellationToken); } private static DbCommand CreateCommand( DbConnection connection, string sql, IReadOnlyList parameters, DbTransaction? transaction = null) { var command = connection.CreateCommand(); command.CommandText = sql; command.Transaction = transaction; foreach (var item in parameters) { var parameter = command.CreateParameter(); parameter.ParameterName = item.Name; parameter.Value = item.Value ?? DBNull.Value; command.Parameters.Add(parameter); } return command; } private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty; private static bool Boolean(JsonObject value, string property) => value[property]?.GetValue() == true; private static object? Optional(string? value) => string.IsNullOrEmpty(value) ? null : value; private static string ReadString(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty; private sealed record QueryParameter(string Name, object? Value); }