diff --git a/e2e/lib/api/schema.ts b/e2e/lib/api/schema.ts index 0fad5f7..f9e7c18 100644 --- a/e2e/lib/api/schema.ts +++ b/e2e/lib/api/schema.ts @@ -436,6 +436,38 @@ export type paths = { patch?: never; trace?: never; }; + "/api/atc/trainings/{id}/self-reflection": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["set_self_reflection"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atc/trainings/{id}/self-reflection-sheet": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_self_reflection_sheet"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/compat/euroscope/metar/metar.php": { parameters: { query?: never; @@ -1682,6 +1714,8 @@ export type components = { name: string; record_sheet_filing?: components["schemas"]["SheetFieldAnswerDto"][] | null; record_sheet_filing_id?: string | null; + self_reflection_sheet_filing?: components["schemas"]["SheetFieldAnswerDto"][] | null; + self_reflection_sheet_filing_id?: string | null; /** Format: date-time */ start_at: string; trainee: components["schemas"]["UserDto"]; @@ -2565,6 +2599,30 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; + get_self_reflection_sheet: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Training ULID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SheetDto"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; get_training: { parameters: { query?: never; @@ -2667,6 +2725,34 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; + set_self_reflection: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Training ULID */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TrainingRecordRequest"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrainingDto"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; get_metar_by_query: { parameters: { query?: { diff --git a/e2e/src/atc/trainings/id/self-reflection.test.ts b/e2e/src/atc/trainings/id/self-reflection.test.ts new file mode 100644 index 0000000..0d8a28c --- /dev/null +++ b/e2e/src/atc/trainings/id/self-reflection.test.ts @@ -0,0 +1,276 @@ +import { expect, test as baseTest } from "vitest"; +import { getClient } from "../../../../lib/backend.js"; + +const test = baseTest + .extend("mentor", async () => getClient(["controller-training-mentor"])) + .extend("trainee", async () => getClient([])) + .extend("training", async ({ mentor, trainee }) => { + const trainerSession = await mentor.GET("/api/session"); + const traineeSession = await trainee.GET("/api/session"); + const result = await mentor.POST("/api/atc/trainings", { + body: { + name: "Self reflection test", + trainer_id: trainerSession.data!.user!.id, + trainee_id: traineeSession.data!.user!.id, + start_at: "2031-06-01T10:00:00Z", + end_at: "2031-06-01T11:00:00Z", + }, + }); + expect(result.response.status).toBe(200); + return result.data!; + }); +const body = (answer: string) => ({ + request_answers: [{ id: "reflection", answer }], +}); + +test("trainee saves before training and edits after training without changing mentor feedback", async ({ + trainee, + mentor, + training, +}) => { + const params = { path: { id: training.id } }; + const sheet = await trainee.GET( + "/api/atc/trainings/{id}/self-reflection-sheet", + { params: { path: { id: training.id } } }, + ); + expect(sheet.data?.fields).toEqual([ + expect.objectContaining({ id: "reflection", kind: "long-text" }), + ]); + expect(training.self_reflection_sheet_filing).toBeNull(); + const first = await trainee.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("初次反思\n需要改进协调"), + }); + expect(first.response.status).toBe(200); + expect(first.data?.self_reflection_sheet_filing?.[0].answer).toBe( + "初次反思\n需要改进协调", + ); + const ended = await mentor.PUT("/api/atc/trainings/{id}", { + params, + body: { + ...training, + start_at: "2020-06-01T10:00:00Z", + end_at: "2020-06-01T11:00:00Z", + }, + }); + expect(ended.response.status).toBe(200); + const second = await trainee.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("修改后的反思"), + }); + expect(second.response.status).toBe(200); + expect(second.data?.self_reflection_sheet_filing_id).toBe( + first.data?.self_reflection_sheet_filing_id, + ); + const loaded = await trainee.GET("/api/atc/trainings/{id}", { params }); + expect(loaded.data?.self_reflection_sheet_filing?.[0].answer).toBe( + "修改后的反思", + ); + expect(loaded.data?.record_sheet_filing).toBeNull(); +}); + +test("mentors can read reflection through training endpoints but only the trainee and training director assistant can write", async ({ + trainee, + mentor, + training, +}) => { + const params = { path: { id: training.id } }; + const admin = await getClient(["controller-training-director-assistant"]); + const first = await admin.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("Admin draft"), + }); + expect(first.response.status).toBe(200); + const saved = await trainee.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("Private reflection"), + }); + expect(saved.response.status).toBe(200); + expect(saved.data?.self_reflection_sheet_filing_id).toBe( + first.data?.self_reflection_sheet_filing_id, + ); + const otherMentor = await getClient(["controller-training-mentor"]); + for (const viewer of [trainee, mentor, otherMentor, admin]) { + expect( + ( + await viewer.GET("/api/atc/trainings/{id}/self-reflection-sheet", { + params, + }) + ).response.status, + ).toBe(200); + const read = await viewer.GET("/api/atc/trainings/{id}", { params }); + expect(read.data?.self_reflection_sheet_filing?.[0].answer).toBe( + "Private reflection", + ); + } + const stranger = await getClient([]); + const staff = await getClient(["staff"]); + for (const denied of [stranger, staff]) { + expect( + ( + await denied.GET("/api/atc/trainings/{id}/self-reflection-sheet", { + params, + }) + ).response.status, + ).toBe(403); + } + for (const denied of [mentor, otherMentor, stranger, staff]) { + expect( + ( + await denied.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("Denied"), + }) + ).response.status, + ).toBe(403); + } + const visible = await otherMentor.GET("/api/atc/trainings/{id}", { params }); + expect(visible.response.status).toBe(200); + expect(visible.data?.self_reflection_sheet_filing).toEqual( + saved.data?.self_reflection_sheet_filing, + ); + expect(visible.data?.self_reflection_sheet_filing_id).toBe( + saved.data?.self_reflection_sheet_filing_id, + ); + const active = await otherMentor.GET("/api/atc/trainings/active"); + expect( + active.data?.find((row) => row.id === training.id) + ?.self_reflection_sheet_filing, + ).toEqual(saved.data?.self_reflection_sheet_filing); + const history = await otherMentor.GET("/api/atc/trainings/by-user/{userId}", { + params: { path: { userId: training.trainee_id } }, + }); + expect( + history.data?.find((row) => row.id === training.id) + ?.self_reflection_sheet_filing, + ).toEqual(saved.data?.self_reflection_sheet_filing); + const updated = await otherMentor.PUT("/api/atc/trainings/{id}", { + params, + body: training, + }); + expect(updated.response.status).toBe(200); + expect(updated.data?.self_reflection_sheet_filing).toEqual( + saved.data?.self_reflection_sheet_filing, + ); + await mentor.GET("/api/atc/trainings/record-sheet"); + const recorded = await otherMentor.PUT("/api/atc/trainings/{id}/record", { + params, + body: { request_answers: [] }, + }); + expect(recorded.response.status).toBe(200); + expect(recorded.data?.self_reflection_sheet_filing).toEqual( + saved.data?.self_reflection_sheet_filing, + ); + const finished = await otherMentor.GET("/api/atc/trainings/finished"); + expect( + finished.data?.find((row) => row.id === training.id) + ?.self_reflection_sheet_filing, + ).toEqual(saved.data?.self_reflection_sheet_filing); + const anonymous = await getClient(); + expect( + ( + await anonymous.GET("/api/atc/trainings/{id}/self-reflection-sheet", { + params, + }) + ).response.status, + ).toBe(401); + expect( + ( + await anonymous.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("Denied"), + }) + ).response.status, + ).toBe(401); + const edited = await admin.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body("Admin edit"), + }); + expect(edited.response.status).toBe(200); + expect(edited.data?.self_reflection_sheet_filing_id).toBe( + first.data?.self_reflection_sheet_filing_id, + ); + expect( + (await trainee.GET("/api/atc/trainings/{id}", { params })).data + ?.self_reflection_sheet_filing?.[0].answer, + ).toBe("Admin edit"); +}); + +test("concurrent initial saves share a filing; invalid fields do not overwrite answers", async ({ + trainee, + training, +}) => { + const params = { path: { id: training.id } }; + const results = await Promise.all( + ["First", "Second"].map((answer) => + trainee.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: body(answer), + }), + ), + ); + results.forEach((result) => expect(result.response.status).toBe(200)); + expect(results[0].data?.self_reflection_sheet_filing_id).toBe( + results[1].data?.self_reflection_sheet_filing_id, + ); + const before = await trainee.GET("/api/atc/trainings/{id}", { params }); + const invalid = await trainee.PUT("/api/atc/trainings/{id}/self-reflection", { + params, + body: { request_answers: [{ id: "unknown", answer: "Bad field" }] }, + }); + expect(invalid.response.ok).toBe(false); + const after = await trainee.GET("/api/atc/trainings/{id}", { params }); + expect(after.data?.self_reflection_sheet_filing).toEqual( + before.data?.self_reflection_sheet_filing, + ); +}); + +test("uses configured fields and excludes deleted fields", async ({ + trainee, + training, +}) => { + const staff = await getClient(["staff"]); + const params = { path: { sheetId: "training-self-reflection" } }; + const original = await staff.GET("/api/sheets/{sheetId}", { params }); + expect(original.response.status).toBe(200); + const custom = { + ...original.data!.fields[0], + id: "next-steps", + name_zh: "改进计划", + name_en: "Next steps", + }; + try { + const configured = await staff.PUT("/api/sheets/{sheetId}", { + params, + body: { name: original.data!.name, fields: [custom] }, + }); + expect(configured.response.status).toBe(200); + const sheet = await trainee.GET( + "/api/atc/trainings/{id}/self-reflection-sheet", + { params: { path: { id: training.id } } }, + ); + expect(sheet.data?.fields.map((field) => field.id)).toEqual(["next-steps"]); + const saved = await trainee.PUT("/api/atc/trainings/{id}/self-reflection", { + params: { path: { id: training.id } }, + body: { + request_answers: [ + { id: "next-steps", answer: "Practice coordination" }, + ], + }, + }); + expect(saved.response.status).toBe(200); + expect(saved.data?.self_reflection_sheet_filing?.[0]).toMatchObject({ + field: { id: "next-steps" }, + answer: "Practice coordination", + }); + } finally { + const restored = await staff.PUT("/api/sheets/{sheetId}", { + params, + body: { + name: original.data!.name, + fields: original.data!.fields.filter((field) => !field.is_deleted), + }, + }); + expect(restored.response.status).toBe(200); + } +}); diff --git a/migrations/20260921000000_add_training_self_reflection.sql b/migrations/20260921000000_add_training_self_reflection.sql new file mode 100644 index 0000000..360fd84 --- /dev/null +++ b/migrations/20260921000000_add_training_self_reflection.sql @@ -0,0 +1,17 @@ +ALTER TABLE public.training + ADD COLUMN self_reflection_sheet_filing_id uuid + REFERENCES public.sheet_filing(id); + +CREATE INDEX ix_training_self_reflection_sheet_filing_id + ON public.training (self_reflection_sheet_filing_id); + +INSERT INTO public.sheet (id, name) +VALUES ('training-self-reflection', 'Training Self Reflection'); + +INSERT INTO public.sheet_field ( + sheet_id, id, sequence, name_zh, name_en, kind, single_choice_options +) +VALUES ( + 'training-self-reflection', 'reflection', 0, + '自我反思', 'Self Reflection', 'long-text', ARRAY[]::text[] +); diff --git a/src/modules/training/dto.rs b/src/modules/training/dto.rs index 1a574f3..1119189 100644 --- a/src/modules/training/dto.rs +++ b/src/modules/training/dto.rs @@ -55,6 +55,8 @@ pub struct TrainingDto { pub deleted_at: Option>, pub record_sheet_filing_id: Option, pub record_sheet_filing: Option>, + pub self_reflection_sheet_filing_id: Option, + pub self_reflection_sheet_filing: Option>, } impl TrainingDto { @@ -63,6 +65,7 @@ impl TrainingDto { trainer: UserSummary, trainee: UserSummary, record_sheet_filing: Option>, + self_reflection_sheet_filing: Option>, ) -> Self { Self { id: Ulid::from(training.id).to_string(), @@ -80,6 +83,10 @@ impl TrainingDto { .record_sheet_filing_id .map(|id| Ulid::from(id).to_string()), record_sheet_filing, + self_reflection_sheet_filing_id: training + .self_reflection_sheet_filing_id + .map(|id| Ulid::from(id).to_string()), + self_reflection_sheet_filing, } } } diff --git a/src/modules/training/models.rs b/src/modules/training/models.rs index a457ea4..849d94b 100644 --- a/src/modules/training/models.rs +++ b/src/modules/training/models.rs @@ -14,6 +14,7 @@ pub struct Training { pub updated_at: DateTime, pub deleted_at: Option>, pub record_sheet_filing_id: Option, + pub self_reflection_sheet_filing_id: Option, } #[derive(Debug, Clone)] diff --git a/src/modules/training/repository/training.rs b/src/modules/training/repository/training.rs index 69aa0eb..dfe4c47 100644 --- a/src/modules/training/repository/training.rs +++ b/src/modules/training/repository/training.rs @@ -20,7 +20,8 @@ fn training_select_sql_from(source: &str, where_clause: &str) -> String { training.created_at, training.updated_at, training.deleted_at, - training.record_sheet_filing_id + training.record_sheet_filing_id, + training.self_reflection_sheet_filing_id FROM {source} {where_clause} "# @@ -44,6 +45,8 @@ pub(crate) trait TrainingRepository<'executor> { async fn find_training_by_id(self, id: Uuid) -> Result, sqlx::Error>; + async fn lock_training_by_id(self, id: Uuid) -> Result, sqlx::Error>; + async fn create_training(self, training: TrainingSave) -> Result; async fn update_training( @@ -58,6 +61,12 @@ pub(crate) trait TrainingRepository<'executor> { filing_id: Uuid, ) -> Result, sqlx::Error>; + async fn set_training_self_reflection_filing( + self, + id: Uuid, + filing_id: Uuid, + ) -> Result, sqlx::Error>; + async fn mark_training_deleted(self, id: Uuid) -> Result; } @@ -121,6 +130,12 @@ where .fetch_optional(self) .await } + async fn lock_training_by_id(self, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Training>(&training_select_sql("WHERE training.id = $1 FOR UPDATE")) + .bind(id) + .fetch_optional(self) + .await + } async fn create_training(self, training: TrainingSave) -> Result { tracing::info!( operation = "create", @@ -216,6 +231,36 @@ where .fetch_optional(self) .await } + async fn set_training_self_reflection_filing( + self, + id: Uuid, + filing_id: Uuid, + ) -> Result, sqlx::Error> { + tracing::info!( + operation = "set_self_reflection_filing", + repository = "src/modules/training/repository/training.rs", + "modifying data" + ); + + let query = format!( + r#" + WITH updated AS ( + UPDATE public.training + SET self_reflection_sheet_filing_id = $2, updated_at = $3 + WHERE id = $1 + RETURNING * + ) + {} + "#, + training_select_sql_from("updated AS training", "WHERE training.id = $1"), + ); + sqlx::query_as::<_, Training>(&query) + .bind(id) + .bind(filing_id) + .bind(Utc::now()) + .fetch_optional(self) + .await + } async fn mark_training_deleted(self, id: Uuid) -> Result { let result = sqlx::query( r#" diff --git a/src/modules/training/routes/trainings.rs b/src/modules/training/routes/trainings.rs index a33977b..d3f82b4 100644 --- a/src/modules/training/routes/trainings.rs +++ b/src/modules/training/routes/trainings.rs @@ -1,16 +1,16 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::routing::get; +use axum::routing::{get, put}; use axum::{Json, Router}; use ulid::Ulid; use crate::error::ApiError; -use crate::modules::user::models::UserRole; use crate::modules::sheet::dto::{SheetDto, SheetFieldAnswerDto}; use crate::modules::sheet::models::SheetAnswerSave; use crate::modules::training::dto::{TrainingDto, TrainingRecordRequest, TrainingSaveRequest}; -use crate::modules::training::service::TrainingView; +use crate::modules::training::service::{SELF_REFLECTION_SHEET_ID, TrainingView}; use crate::modules::user::middleware::CurrentUser; +use crate::modules::user::models::UserRole; use crate::services::Services; #[derive(utoipa::OpenApi)] @@ -23,7 +23,9 @@ use crate::services::Services; get_training, update_training, delete_training, - set_record_sheet + set_record_sheet, + get_self_reflection_sheet, + set_self_reflection ))] pub(crate) struct ApiDoc; @@ -36,13 +38,18 @@ pub fn build_training_routes() -> Router { .route("/by-user/{user_id}", get(list_by_user)) .route("/finished", get(list_finished)) .route("/record-sheet", get(get_record_sheet)) + .route( + "/{id}/self-reflection-sheet", + get(get_self_reflection_sheet), + ) + .route("/{id}/self-reflection", put(set_self_reflection)) .route( "/{id}", get(get_training) .put(update_training) .delete(delete_training), ) - .route("/{id}/record", axum::routing::put(set_record_sheet)) + .route("/{id}/record", put(set_record_sheet)) } #[utoipa::path(get, path = "api/atc/trainings/active", tag = "Training", security(("oauth2" = [])), responses((status = 200, description = "Successful response", body = Vec)))] @@ -201,6 +208,56 @@ async fn set_record_sheet( Ok(Json(training_to_dto(training))) } +#[utoipa::path(get, path = "api/atc/trainings/{id}/self-reflection-sheet", tag = "Training", security(("oauth2" = [])), params(("id" = String, Path, description = "Training ULID")), responses((status = 200, description = "Successful response", body = SheetDto)))] +async fn get_self_reflection_sheet( + State(services): State, + current_user: CurrentUser, + Path(id): Path, +) -> Result, ApiError> { + let user_id = current_user.user_id.ok_or(ApiError::Unauthorized)?; + services + .training() + .find_visible( + id.parse::()?.into(), + user_id, + is_training_history_admin(¤t_user), + ) + .await?; + let view = services.sheet().find(SELF_REFLECTION_SHEET_ID).await?; + Ok(Json(SheetDto::from_entities( + view.sheet, + view.fields + .into_iter() + .filter(|field| !field.is_deleted) + .collect(), + ))) +} + +#[utoipa::path(put, path = "api/atc/trainings/{id}/self-reflection", tag = "Training", security(("oauth2" = [])), params(("id" = String, Path, description = "Training ULID")), request_body = TrainingRecordRequest, responses((status = 200, description = "Successful response", body = TrainingDto)))] +async fn set_self_reflection( + State(services): State, + current_user: CurrentUser, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let user_id = current_user.user_id.ok_or(ApiError::Unauthorized)?; + let answers = request + .request_answers + .into_iter() + .map(SheetAnswerSave::from) + .collect::>(); + let training = services + .training() + .set_self_reflection( + id.parse::()?.into(), + &answers, + user_id, + current_user.has_role(UserRole::ControllerTrainingDirectorAssistant), + ) + .await?; + Ok(Json(training_to_dto(training))) +} + #[utoipa::path(delete, path = "api/atc/trainings/{id}", tag = "Training", security(("oauth2" = [])), params(("id" = String, Path, description = "Training ULID")), responses((status = 204, description = "No content")))] async fn delete_training( State(services): State, @@ -231,6 +288,12 @@ fn training_to_dto(view: TrainingView) -> TrainingDto { .map(|view| SheetFieldAnswerDto::from_entities(view.answer, view.field)) .collect() }), + view.self_reflection_sheet_filing.map(|answers| { + answers + .into_iter() + .map(|view| SheetFieldAnswerDto::from_entities(view.answer, view.field)) + .collect() + }), ) } diff --git a/src/modules/training/service.rs b/src/modules/training/service.rs index 4f8a112..7d30fa4 100644 --- a/src/modules/training/service.rs +++ b/src/modules/training/service.rs @@ -24,6 +24,8 @@ use super::repository::training_application_response::{ }; use super::repository::training_application_slot::TrainingApplicationSlotRepository; +pub const SELF_REFLECTION_SHEET_ID: &str = "training-self-reflection"; + const RECORD_SHEET_ID: &str = "training-record"; #[derive(Clone)] @@ -159,6 +161,41 @@ impl TrainingService { self.with_filing(training).await } + pub async fn set_self_reflection( + &self, + id: Uuid, + answers: &[SheetAnswerSave], + current_user_id: Uuid, + is_admin: bool, + ) -> Result { + let mut transaction = self.db.begin().await?; + // Serialize edits, including the first filing, for this training. + let training = (&mut *transaction) + .lock_training_by_id(id) + .await? + .ok_or(TrainingServiceError::NotFound(id))?; + if training.trainee_id != current_user_id && !is_admin { + return Err(TrainingServiceError::NotOwned { + entity: "training", + id, + }); + } + let filing_id = transaction + .set_sheet_filing( + SELF_REFLECTION_SHEET_ID, + training.self_reflection_sheet_filing_id, + training.trainee_id, + answers, + ) + .await?; + let training = (&mut *transaction) + .set_training_self_reflection_filing(id, filing_id) + .await? + .ok_or(TrainingServiceError::NotFound(id))?; + transaction.commit().await?; + self.with_filing(training).await + } + pub async fn delete( &self, id: Uuid, @@ -197,6 +234,10 @@ impl TrainingService { Some(filing_id) => Some(self.sheet.filing_answers(filing_id).await?), None => None, }; + let self_reflection_sheet_filing = match training.self_reflection_sheet_filing_id { + Some(filing_id) => Some(self.sheet.filing_answers(filing_id).await?), + None => None, + }; let trainer = self .user .find_summary_by_id(training.trainer_id) @@ -212,6 +253,7 @@ impl TrainingService { trainer, trainee, record_sheet_filing, + self_reflection_sheet_filing, }) } } @@ -222,6 +264,7 @@ pub struct TrainingView { pub trainer: UserSummary, pub trainee: UserSummary, pub record_sheet_filing: Option>, + pub self_reflection_sheet_filing: Option>, } fn ensure_trainer_access(