// Package model — DTO/доменные структуры learning-сервиса. Поля -> JSON // маппятся 1-в-1 на сообщения frontend'а; cross-service id'шники (owner_user_id, // candidate_id) — opaque UUID/int64, портал валидирует существование сам. package model import ( "time" "github.com/google/uuid" ) // ============================================================ // TESTS // ============================================================ type Test struct { ID uuid.UUID `json:"id"` Title string `json:"title"` Description string `json:"description"` PassingScore *int `json:"passing_score"` MaxAttempts int `json:"max_attempts"` TimeLimitSec int `json:"time_limit_sec"` ShuffleQuestions bool `json:"shuffle_questions"` ShowCorrectAfter bool `json:"show_correct_after"` IsPublished bool `json:"is_published"` OwnerUserID uuid.UUID `json:"owner_user_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // QuestionsCount — populate'ится через correlated subquery в List // (не отдельной таблицей), даёт UI быстрый бейдж «N вопросов». QuestionsCount int `json:"questions_count,omitempty"` } type CreateTestRequest struct { Title string `json:"title"` Description string `json:"description"` PassingScore *int `json:"passing_score"` MaxAttempts int `json:"max_attempts"` TimeLimitSec int `json:"time_limit_sec"` ShuffleQuestions bool `json:"shuffle_questions"` ShowCorrectAfter bool `json:"show_correct_after"` } // UpdateTestRequest — все поля nullable, обновляем только заполненные // (паттерн COALESCE($X, current) или conditional UPDATE). type UpdateTestRequest struct { Title *string `json:"title"` Description *string `json:"description"` PassingScore *int `json:"passing_score"` MaxAttempts *int `json:"max_attempts"` TimeLimitSec *int `json:"time_limit_sec"` ShuffleQuestions *bool `json:"shuffle_questions"` ShowCorrectAfter *bool `json:"show_correct_after"` IsPublished *bool `json:"is_published"` } type Question struct { ID uuid.UUID `json:"id"` TestID uuid.UUID `json:"test_id"` Position int `json:"position"` Kind string `json:"kind"` // single | multi | text Text string `json:"text"` Points int `json:"points"` Explanation string `json:"explanation"` CreatedAt time.Time `json:"created_at"` Answers []Answer `json:"answers,omitempty"` } type Answer struct { ID uuid.UUID `json:"id"` QuestionID uuid.UUID `json:"question_id"` Position int `json:"position"` Text string `json:"text"` // IsCorrect — фильтруется handler'ом при выдаче респонденту: показываем // только после сабмита (если show_correct_after) или владельцу теста. IsCorrect bool `json:"is_correct,omitempty"` CreatedAt time.Time `json:"created_at"` } type CreateQuestionRequest struct { Position int `json:"position"` Kind string `json:"kind"` Text string `json:"text"` Points int `json:"points"` Explanation string `json:"explanation"` Answers []CreateAnswerRequest `json:"answers"` } type CreateAnswerRequest struct { Position int `json:"position"` Text string `json:"text"` IsCorrect bool `json:"is_correct"` } // ============================================================ // ATTEMPTS // ============================================================ type Attempt struct { ID uuid.UUID `json:"id"` TestID uuid.UUID `json:"test_id"` UserID *uuid.UUID `json:"user_id"` PublicTokenID *uuid.UUID `json:"public_token_id"` CandidateID *int64 `json:"candidate_id"` Status string `json:"status"` // in_progress | submitted | graded | expired Score *int `json:"score"` MaxScore *int `json:"max_score"` Passed *bool `json:"passed"` RespondentName string `json:"respondent_name"` RespondentEmail string `json:"respondent_email"` StartedAt time.Time `json:"started_at"` SubmittedAt *time.Time `json:"submitted_at"` GradedAt *time.Time `json:"graded_at"` IP string `json:"ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` } // SubmitAttemptRequest — отправка ответов. AttemptAnswers идут одной // пачкой: для каждого вопроса либо single/multi (по id'шникам), либо text. type SubmitAttemptRequest struct { Answers []SubmitAnswer `json:"answers"` } type SubmitAnswer struct { QuestionID uuid.UUID `json:"question_id"` AnswerID *uuid.UUID `json:"answer_id,omitempty"` // single AnswerIDs []uuid.UUID `json:"answer_ids,omitempty"` // multi Text string `json:"text,omitempty"` // text } // ============================================================ // COURSES // ============================================================ type Course struct { ID uuid.UUID `json:"id"` Title string `json:"title"` Description string `json:"description"` Slug string `json:"slug"` CoverImageKey string `json:"cover_image_key"` IsPublished bool `json:"is_published"` OwnerUserID uuid.UUID `json:"owner_user_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LessonsCount int `json:"lessons_count,omitempty"` } type CreateCourseRequest struct { Title string `json:"title"` Description string `json:"description"` Slug string `json:"slug"` } type UpdateCourseRequest struct { Title *string `json:"title"` Description *string `json:"description"` Slug *string `json:"slug"` CoverImageKey *string `json:"cover_image_key"` IsPublished *bool `json:"is_published"` } type Lesson struct { ID uuid.UUID `json:"id"` CourseID uuid.UUID `json:"course_id"` Position int `json:"position"` Title string `json:"title"` Markdown string `json:"markdown"` VideoKey string `json:"video_key"` VideoDurationSec int `json:"video_duration_sec"` TestID *uuid.UUID `json:"test_id"` IsRequired bool `json:"is_required"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type CreateLessonRequest struct { Position int `json:"position"` Title string `json:"title"` Markdown string `json:"markdown"` TestID *uuid.UUID `json:"test_id"` IsRequired *bool `json:"is_required"` } type UpdateLessonRequest struct { Position *int `json:"position"` Title *string `json:"title"` Markdown *string `json:"markdown"` VideoKey *string `json:"video_key"` VideoDurationSec *int `json:"video_duration_sec"` TestID *uuid.UUID `json:"test_id"` IsRequired *bool `json:"is_required"` } // ============================================================ // ACCESS GRANTS // ============================================================ type AccessGrant struct { ID uuid.UUID `json:"id"` ResourceType string `json:"resource_type"` // test | course ResourceID uuid.UUID `json:"resource_id"` SubjectType string `json:"subject_type"` // user | role | department | position | public SubjectID *uuid.UUID `json:"subject_id"` CanManage bool `json:"can_manage"` GrantedBy uuid.UUID `json:"granted_by"` CreatedAt time.Time `json:"created_at"` } type CreateAccessGrantRequest struct { SubjectType string `json:"subject_type"` SubjectID *uuid.UUID `json:"subject_id"` CanManage bool `json:"can_manage"` } // ============================================================ // PUBLIC TOKENS // ============================================================ type PublicToken struct { ID uuid.UUID `json:"id"` Token string `json:"token"` ResourceType string `json:"resource_type"` ResourceID uuid.UUID `json:"resource_id"` IntendedEmail string `json:"intended_email"` CandidateID *int64 `json:"candidate_id"` MaxAttempts int `json:"max_attempts"` UsedAttempts int `json:"used_attempts"` ExpiresAt *time.Time `json:"expires_at"` OpenedAt *time.Time `json:"opened_at"` UsedAt *time.Time `json:"used_at"` RevokedAt *time.Time `json:"revoked_at"` CreatedBy uuid.UUID `json:"created_by"` CreatedAt time.Time `json:"created_at"` } type CreatePublicTokenRequest struct { ResourceType string `json:"resource_type"` ResourceID uuid.UUID `json:"resource_id"` IntendedEmail string `json:"intended_email"` CandidateID *int64 `json:"candidate_id"` MaxAttempts int `json:"max_attempts"` ExpiresAt *time.Time `json:"expires_at"` } // PublicTokenResolveRequest — что приходит при «открытии» public-ссылки: // email из формы, его сверяем с intended_email. UA/IP берём из request'а. type PublicTokenResolveRequest struct { Email string `json:"email"` }