From 374c3b4f4799e8aaf4c37281ad7e7a2ca629f924 Mon Sep 17 00:00:00 2001 From: AzenKain Date: Sun, 17 May 2026 22:25:48 +0700 Subject: [PATCH] feat: implement battle replay module with database migrations, repository, and CRUD service endpoints --- FinalProject.drawio | 8902 +++++++++++++++++ cmd/api/server.go | 6 +- db/migrations/000017_battle_replays.down.sql | 1 + db/migrations/000017_battle_replays.up.sql | 28 + db/query/battle_replay.sql | 49 + db/schema.sql | 11 + docs/docs.go | 109 + docs/swagger.json | 109 + docs/swagger.yaml | 72 + .../controllers/battleReplayController.go | 72 + internal/dtos/request/snapshot.go | 8 + internal/dtos/response/battle_replay.go | 17 + internal/gen/sqlc/battle_replay.sql.go | 272 + internal/gen/sqlc/models.go | 11 + internal/models/battle_replay.go | 48 + .../repositories/battleReplayRepository.go | 290 + internal/routes/battleReplayRoute.go | 13 + internal/services/battleReplayService.go | 54 + internal/services/submissionService.go | 123 +- 19 files changed, 10169 insertions(+), 26 deletions(-) create mode 100644 FinalProject.drawio create mode 100644 db/migrations/000017_battle_replays.down.sql create mode 100644 db/migrations/000017_battle_replays.up.sql create mode 100644 db/query/battle_replay.sql create mode 100644 internal/controllers/battleReplayController.go create mode 100644 internal/dtos/response/battle_replay.go create mode 100644 internal/gen/sqlc/battle_replay.sql.go create mode 100644 internal/models/battle_replay.go create mode 100644 internal/repositories/battleReplayRepository.go create mode 100644 internal/routes/battleReplayRoute.go create mode 100644 internal/services/battleReplayService.go diff --git a/FinalProject.drawio b/FinalProject.drawio new file mode 100644 index 0000000..fd86bd6 --- /dev/null +++ b/FinalProject.drawio @@ -0,0 +1,8902 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cmd/api/server.go b/cmd/api/server.go index 6b0e78b..5dcdb61 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -98,6 +98,7 @@ func (s *FiberServer) SetupServer( usageRepo := repositories.NewUsageRepository(redis) statisticRepo := repositories.NewStatisticRepository(poolPg, redis) chatRepo := repositories.NewChatRepository(poolPg, redis) + battleReplayRepo := repositories.NewBattleReplayRepository(poolPg, redis) // service setup authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis, poolPg) @@ -115,10 +116,11 @@ func (s *FiberServer) SetupServer( submissionService := services.NewSubmissionService( submissionRepo, projectRepo, commitRepo, userRepo, wikiRepo, geometryRepo, entityRepo, - raguRepo, raguUtils, poolPg, redis, + battleReplayRepo, raguRepo, raguUtils, poolPg, redis, ) chatbotService := services.NewChatbotService(raguRepo, usageRepo, chatRepo, raguUtils) statisticService := services.NewStatisticService(statisticRepo) + battleReplayService := services.NewBattleReplayService(battleReplayRepo) // controller setup authController := controllers.NewAuthController(authService, oauth) @@ -136,6 +138,7 @@ func (s *FiberServer) SetupServer( submissionController := controllers.NewSubmissionController(submissionService) chatbotController := controllers.NewChatbotController(chatbotService) statisticController := controllers.NewStatisticController(statisticService) + battleReplayController := controllers.NewBattleReplayController(battleReplayService) // route setup routes.AuthRoutes(s.App, authController, userRepo) @@ -152,5 +155,6 @@ func (s *FiberServer) SetupServer( routes.SubmissionRoutes(s.App, submissionController, userRepo) routes.ChatbotRoutes(s.App, chatbotController, userRepo) routes.StatisticRoutes(s.App, statisticController, userRepo) + routes.BattleReplayRoutes(s.App, battleReplayController) routes.NotFoundRoute(s.App) } diff --git a/db/migrations/000017_battle_replays.down.sql b/db/migrations/000017_battle_replays.down.sql new file mode 100644 index 0000000..1dba21f --- /dev/null +++ b/db/migrations/000017_battle_replays.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS battle_replays; diff --git a/db/migrations/000017_battle_replays.up.sql b/db/migrations/000017_battle_replays.up.sql new file mode 100644 index 0000000..56ad7bf --- /dev/null +++ b/db/migrations/000017_battle_replays.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS battle_replays ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + geometry_id UUID NOT NULL REFERENCES geometries(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + target_geometry_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + is_deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_battle_replays_geometry_id ON battle_replays(geometry_id) + WHERE is_deleted = false; + +CREATE INDEX idx_battle_replays_project_id ON battle_replays(project_id) + WHERE is_deleted = false; + +CREATE INDEX idx_battle_replays_target_geometry_ids ON battle_replays USING GIN (target_geometry_ids) + WHERE is_deleted = false; + +CREATE INDEX idx_battle_replays_updated_at ON battle_replays(updated_at DESC) + WHERE is_deleted = false; + +DROP TRIGGER IF EXISTS trigger_battle_replays_updated_at ON battle_replays; +CREATE TRIGGER trigger_battle_replays_updated_at +BEFORE UPDATE ON battle_replays +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); diff --git a/db/query/battle_replay.sql b/db/query/battle_replay.sql new file mode 100644 index 0000000..3255166 --- /dev/null +++ b/db/query/battle_replay.sql @@ -0,0 +1,49 @@ +-- name: CreateBattleReplay :one +INSERT INTO battle_replays ( + id, geometry_id, project_id, target_geometry_ids, detail +) VALUES ( + COALESCE(sqlc.narg('id')::uuid, uuidv7()), $1, $2, $3, $4 +) +RETURNING *; + +-- name: GetBattleReplayById :one +SELECT * +FROM battle_replays +WHERE id = $1 AND is_deleted = false; + +-- name: GetBattleReplaysByIDs :many +SELECT * FROM battle_replays WHERE id = ANY($1::uuid[]) AND is_deleted = false; + +-- name: GetBattleReplaysByGeometryId :many +SELECT * +FROM battle_replays +WHERE geometry_id = $1 AND is_deleted = false; + +-- name: GetBattleReplaysByGeometryIDs :many +SELECT * +FROM battle_replays +WHERE geometry_id = ANY($1::uuid[]) AND is_deleted = false; + +-- name: GetBattleReplaysByProjectId :many +SELECT * +FROM battle_replays +WHERE project_id = $1 AND is_deleted = false; + +-- name: UpdateBattleReplay :one +UPDATE battle_replays +SET + geometry_id = COALESCE(sqlc.narg('geometry_id'), geometry_id), + target_geometry_ids = COALESCE(sqlc.narg('target_geometry_ids'), target_geometry_ids), + detail = COALESCE(sqlc.narg('detail'), detail) +WHERE id = sqlc.arg('id') AND is_deleted = false +RETURNING *; + +-- name: DeleteBattleReplay :exec +UPDATE battle_replays +SET is_deleted = true +WHERE id = $1; + +-- name: DeleteBattleReplaysByIDs :exec +UPDATE battle_replays +SET is_deleted = true +WHERE id = ANY($1::uuid[]); diff --git a/db/schema.sql b/db/schema.sql index db26285..1a4af03 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -243,3 +243,14 @@ CREATE TABLE IF NOT EXISTS chatbot_histories ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS battle_replays ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + geometry_id UUID NOT NULL REFERENCES geometries(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + target_geometry_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + is_deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + diff --git a/docs/docs.go b/docs/docs.go index 6f1af9d..3491440 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -399,6 +399,82 @@ const docTemplate = `{ } } }, + "/battle-replays/geometry/{geometryId}": { + "get": { + "description": "Get all battle replays associated with a specific geometry", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "BattleReplays" + ], + "summary": "Get battle replays by geometry ID", + "parameters": [ + { + "type": "string", + "description": "Geometry ID", + "name": "geometryId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/battle-replays/{id}": { + "get": { + "description": "Get detailed information about a specific battle replay", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "BattleReplays" + ], + "summary": "Get battle replay by ID", + "parameters": [ + { + "type": "string", + "description": "Battle Replay ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/chatbot/chat": { "post": { "security": [ @@ -4241,6 +4317,33 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.BattleReplaySnapshot": { + "type": "object", + "required": [ + "geometry_id", + "id" + ], + "properties": { + "detail": { + "type": "array", + "items": { + "type": "integer" + } + }, + "geometry_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "target_geometry_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "history-api_internal_dtos_request.ChangeOwnerDto": { "type": "object", "required": [ @@ -4329,6 +4432,12 @@ const docTemplate = `{ "$ref": "#/definitions/history-api_internal_dtos_request.GeometryEntitySnapshot" } }, + "replays": { + "type": "array", + "items": { + "$ref": "#/definitions/history-api_internal_dtos_request.BattleReplaySnapshot" + } + }, "wikis": { "type": "array", "items": { diff --git a/docs/swagger.json b/docs/swagger.json index a76e231..9d4b7d8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -392,6 +392,82 @@ } } }, + "/battle-replays/geometry/{geometryId}": { + "get": { + "description": "Get all battle replays associated with a specific geometry", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "BattleReplays" + ], + "summary": "Get battle replays by geometry ID", + "parameters": [ + { + "type": "string", + "description": "Geometry ID", + "name": "geometryId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/battle-replays/{id}": { + "get": { + "description": "Get detailed information about a specific battle replay", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "BattleReplays" + ], + "summary": "Get battle replay by ID", + "parameters": [ + { + "type": "string", + "description": "Battle Replay ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/chatbot/chat": { "post": { "security": [ @@ -4234,6 +4310,33 @@ } } }, + "history-api_internal_dtos_request.BattleReplaySnapshot": { + "type": "object", + "required": [ + "geometry_id", + "id" + ], + "properties": { + "detail": { + "type": "array", + "items": { + "type": "integer" + } + }, + "geometry_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "target_geometry_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "history-api_internal_dtos_request.ChangeOwnerDto": { "type": "object", "required": [ @@ -4322,6 +4425,12 @@ "$ref": "#/definitions/history-api_internal_dtos_request.GeometryEntitySnapshot" } }, + "replays": { + "type": "array", + "items": { + "$ref": "#/definitions/history-api_internal_dtos_request.BattleReplaySnapshot" + } + }, "wikis": { "type": "array", "items": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7ae70dc..a24ecc7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -29,6 +29,24 @@ definitions: - min_lat - min_lng type: object + history-api_internal_dtos_request.BattleReplaySnapshot: + properties: + detail: + items: + type: integer + type: array + geometry_id: + type: string + id: + type: string + target_geometry_ids: + items: + type: string + type: array + required: + - geometry_id + - id + type: object history-api_internal_dtos_request.ChangeOwnerDto: properties: new_owner_id: @@ -88,6 +106,10 @@ definitions: items: $ref: '#/definitions/history-api_internal_dtos_request.GeometryEntitySnapshot' type: array + replays: + items: + $ref: '#/definitions/history-api_internal_dtos_request.BattleReplaySnapshot' + type: array wikis: items: $ref: '#/definitions/history-api_internal_dtos_request.WikiSnapshot' @@ -947,6 +969,56 @@ paths: summary: Verify a security token tags: - Auth + /battle-replays/{id}: + get: + consumes: + - application/json + description: Get detailed information about a specific battle replay + parameters: + - description: Battle Replay ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Get battle replay by ID + tags: + - BattleReplays + /battle-replays/geometry/{geometryId}: + get: + consumes: + - application/json + description: Get all battle replays associated with a specific geometry + parameters: + - description: Geometry ID + in: path + name: geometryId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Get battle replays by geometry ID + tags: + - BattleReplays /chatbot/chat: post: consumes: diff --git a/internal/controllers/battleReplayController.go b/internal/controllers/battleReplayController.go new file mode 100644 index 0000000..4bfb93e --- /dev/null +++ b/internal/controllers/battleReplayController.go @@ -0,0 +1,72 @@ +package controllers + +import ( + "context" + "history-api/internal/dtos/response" + "history-api/internal/services" + "time" + + "github.com/gofiber/fiber/v3" +) + +type BattleReplayController struct { + service services.BattleReplayService +} + +func NewBattleReplayController(svc services.BattleReplayService) *BattleReplayController { + return &BattleReplayController{service: svc} +} + +// GetBattleReplayById handles fetching a single battle replay by ID. +// @Summary Get battle replay by ID +// @Description Get detailed information about a specific battle replay +// @Tags BattleReplays +// @Accept json +// @Produce json +// @Param id path string true "Battle Replay ID" +// @Success 200 {object} response.CommonResponse +// @Failure 404 {object} response.CommonResponse +// @Router /battle-replays/{id} [get] +func (h *BattleReplayController) GetBattleReplayById(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + id := c.Params("id") + res, err := h.service.GetByID(ctx, id) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// GetBattleReplaysByGeometryId handles fetching battle replays by geometry ID. +// @Summary Get battle replays by geometry ID +// @Description Get all battle replays associated with a specific geometry +// @Tags BattleReplays +// @Accept json +// @Produce json +// @Param geometryId path string true "Geometry ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Router /battle-replays/geometry/{geometryId} [get] +func (h *BattleReplayController) GetBattleReplaysByGeometryId(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + geometryID := c.Params("geometryId") + res, err := h.service.GetByGeometryID(ctx, geometryID) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} diff --git a/internal/dtos/request/snapshot.go b/internal/dtos/request/snapshot.go index 9e1b7b3..c0231e3 100644 --- a/internal/dtos/request/snapshot.go +++ b/internal/dtos/request/snapshot.go @@ -9,6 +9,14 @@ type CommitSnapshot struct { Wikis []*WikiSnapshot `json:"wikis,omitempty" validate:"omitempty,dive"` GeometryEntity []*GeometryEntitySnapshot `json:"geometry_entity,omitempty" validate:"omitempty,dive"` EntityWiki []*EntityWikiLinkSnapshot `json:"entity_wiki,omitempty" validate:"omitempty,dive"` + Replays []*BattleReplaySnapshot `json:"replays,omitempty" validate:"omitempty,dive"` +} + +type BattleReplaySnapshot struct { + ID string `json:"id" validate:"required,uuidv7"` + GeometryID string `json:"geometry_id" validate:"required,uuidv7"` + TargetGeometryIDs []string `json:"target_geometry_ids,omitempty" validate:"omitempty,dive,uuidv7"` + Detail json.RawMessage `json:"detail,omitempty"` } type FeatureCollection struct { diff --git a/internal/dtos/response/battle_replay.go b/internal/dtos/response/battle_replay.go new file mode 100644 index 0000000..cb7c2a6 --- /dev/null +++ b/internal/dtos/response/battle_replay.go @@ -0,0 +1,17 @@ +package response + +import ( + "encoding/json" + "time" +) + +type BattleReplayResponse struct { + ID string `json:"id"` + GeometryID string `json:"geometry_id"` + ProjectID string `json:"project_id"` + TargetGeometryIDs json.RawMessage `json:"target_geometry_ids,omitempty"` + Detail json.RawMessage `json:"detail,omitempty"` + IsDeleted bool `json:"is_deleted,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} diff --git a/internal/gen/sqlc/battle_replay.sql.go b/internal/gen/sqlc/battle_replay.sql.go new file mode 100644 index 0000000..2e071f4 --- /dev/null +++ b/internal/gen/sqlc/battle_replay.sql.go @@ -0,0 +1,272 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: battle_replay.sql + +package sqlc + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createBattleReplay = `-- name: CreateBattleReplay :one +INSERT INTO battle_replays ( + id, geometry_id, project_id, target_geometry_ids, detail +) VALUES ( + COALESCE($5::uuid, uuidv7()), $1, $2, $3, $4 +) +RETURNING id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at +` + +type CreateBattleReplayParams struct { + GeometryID pgtype.UUID `json:"geometry_id"` + ProjectID pgtype.UUID `json:"project_id"` + TargetGeometryIds json.RawMessage `json:"target_geometry_ids"` + Detail json.RawMessage `json:"detail"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) CreateBattleReplay(ctx context.Context, arg CreateBattleReplayParams) (BattleReplay, error) { + row := q.db.QueryRow(ctx, createBattleReplay, + arg.GeometryID, + arg.ProjectID, + arg.TargetGeometryIds, + arg.Detail, + arg.ID, + ) + var i BattleReplay + err := row.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteBattleReplay = `-- name: DeleteBattleReplay :exec +UPDATE battle_replays +SET is_deleted = true +WHERE id = $1 +` + +func (q *Queries) DeleteBattleReplay(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteBattleReplay, id) + return err +} + +const deleteBattleReplaysByIDs = `-- name: DeleteBattleReplaysByIDs :exec +UPDATE battle_replays +SET is_deleted = true +WHERE id = ANY($1::uuid[]) +` + +func (q *Queries) DeleteBattleReplaysByIDs(ctx context.Context, dollar_1 []pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteBattleReplaysByIDs, dollar_1) + return err +} + +const getBattleReplayById = `-- name: GetBattleReplayById :one +SELECT id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at +FROM battle_replays +WHERE id = $1 AND is_deleted = false +` + +func (q *Queries) GetBattleReplayById(ctx context.Context, id pgtype.UUID) (BattleReplay, error) { + row := q.db.QueryRow(ctx, getBattleReplayById, id) + var i BattleReplay + err := row.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getBattleReplaysByGeometryIDs = `-- name: GetBattleReplaysByGeometryIDs :many +SELECT id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at +FROM battle_replays +WHERE geometry_id = ANY($1::uuid[]) AND is_deleted = false +` + +func (q *Queries) GetBattleReplaysByGeometryIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]BattleReplay, error) { + rows, err := q.db.Query(ctx, getBattleReplaysByGeometryIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []BattleReplay{} + for rows.Next() { + var i BattleReplay + if err := rows.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getBattleReplaysByGeometryId = `-- name: GetBattleReplaysByGeometryId :many +SELECT id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at +FROM battle_replays +WHERE geometry_id = $1 AND is_deleted = false +` + +func (q *Queries) GetBattleReplaysByGeometryId(ctx context.Context, geometryID pgtype.UUID) ([]BattleReplay, error) { + rows, err := q.db.Query(ctx, getBattleReplaysByGeometryId, geometryID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []BattleReplay{} + for rows.Next() { + var i BattleReplay + if err := rows.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getBattleReplaysByIDs = `-- name: GetBattleReplaysByIDs :many +SELECT id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at FROM battle_replays WHERE id = ANY($1::uuid[]) AND is_deleted = false +` + +func (q *Queries) GetBattleReplaysByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]BattleReplay, error) { + rows, err := q.db.Query(ctx, getBattleReplaysByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []BattleReplay{} + for rows.Next() { + var i BattleReplay + if err := rows.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getBattleReplaysByProjectId = `-- name: GetBattleReplaysByProjectId :many +SELECT id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at +FROM battle_replays +WHERE project_id = $1 AND is_deleted = false +` + +func (q *Queries) GetBattleReplaysByProjectId(ctx context.Context, projectID pgtype.UUID) ([]BattleReplay, error) { + rows, err := q.db.Query(ctx, getBattleReplaysByProjectId, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []BattleReplay{} + for rows.Next() { + var i BattleReplay + if err := rows.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateBattleReplay = `-- name: UpdateBattleReplay :one +UPDATE battle_replays +SET + geometry_id = COALESCE($1, geometry_id), + target_geometry_ids = COALESCE($2, target_geometry_ids), + detail = COALESCE($3, detail) +WHERE id = $4 AND is_deleted = false +RETURNING id, geometry_id, project_id, target_geometry_ids, detail, is_deleted, created_at, updated_at +` + +type UpdateBattleReplayParams struct { + GeometryID pgtype.UUID `json:"geometry_id"` + TargetGeometryIds []byte `json:"target_geometry_ids"` + Detail []byte `json:"detail"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) UpdateBattleReplay(ctx context.Context, arg UpdateBattleReplayParams) (BattleReplay, error) { + row := q.db.QueryRow(ctx, updateBattleReplay, + arg.GeometryID, + arg.TargetGeometryIds, + arg.Detail, + arg.ID, + ) + var i BattleReplay + err := row.Scan( + &i.ID, + &i.GeometryID, + &i.ProjectID, + &i.TargetGeometryIds, + &i.Detail, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index 3edb291..1ded2f6 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -11,6 +11,17 @@ import ( "github.com/pgvector/pgvector-go" ) +type BattleReplay struct { + ID pgtype.UUID `json:"id"` + GeometryID pgtype.UUID `json:"geometry_id"` + ProjectID pgtype.UUID `json:"project_id"` + TargetGeometryIds json.RawMessage `json:"target_geometry_ids"` + Detail json.RawMessage `json:"detail"` + IsDeleted bool `json:"is_deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type ChatbotHistory struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` diff --git a/internal/models/battle_replay.go b/internal/models/battle_replay.go new file mode 100644 index 0000000..a73f5e3 --- /dev/null +++ b/internal/models/battle_replay.go @@ -0,0 +1,48 @@ +package models + +import ( + "encoding/json" + "history-api/internal/dtos/response" + "time" +) + +type BattleReplayEntity struct { + ID string `json:"id"` + GeometryID string `json:"geometry_id"` + ProjectID string `json:"project_id"` + TargetGeometryIDs json.RawMessage `json:"target_geometry_ids"` + Detail json.RawMessage `json:"detail"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +func (b *BattleReplayEntity) ToResponse() *response.BattleReplayResponse { + if b == nil { + return nil + } + return &response.BattleReplayResponse{ + ID: b.ID, + GeometryID: b.GeometryID, + ProjectID: b.ProjectID, + TargetGeometryIDs: b.TargetGeometryIDs, + Detail: b.Detail, + IsDeleted: b.IsDeleted, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } +} + +func BattleReplaysEntityToResponse(bs []*BattleReplayEntity) []*response.BattleReplayResponse { + out := make([]*response.BattleReplayResponse, 0) + if bs == nil { + return out + } + for _, b := range bs { + if b == nil { + continue + } + out = append(out, b.ToResponse()) + } + return out +} diff --git a/internal/repositories/battleReplayRepository.go b/internal/repositories/battleReplayRepository.go new file mode 100644 index 0000000..77a228e --- /dev/null +++ b/internal/repositories/battleReplayRepository.go @@ -0,0 +1,290 @@ +package repositories + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" +) + +type BattleReplayRepository interface { + GetByID(ctx context.Context, id pgtype.UUID) (*models.BattleReplayEntity, error) + GetByIDs(ctx context.Context, ids []string) ([]*models.BattleReplayEntity, error) + GetByGeometryID(ctx context.Context, geometryID pgtype.UUID) ([]*models.BattleReplayEntity, error) + GetByGeometryIDs(ctx context.Context, geometryIDs []string) ([]*models.BattleReplayEntity, error) + Create(ctx context.Context, params sqlc.CreateBattleReplayParams) (*models.BattleReplayEntity, error) + Update(ctx context.Context, params sqlc.UpdateBattleReplayParams) (*models.BattleReplayEntity, error) + Delete(ctx context.Context, id pgtype.UUID) error + DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error + GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.BattleReplayEntity, error) + WithTx(tx pgx.Tx) BattleReplayRepository +} + +type battleReplayRepository struct { + q *sqlc.Queries + c cache.Cache +} + +func NewBattleReplayRepository(db sqlc.DBTX, c cache.Cache) BattleReplayRepository { + return &battleReplayRepository{ + q: sqlc.New(db), + c: c, + } +} + +func (r *battleReplayRepository) WithTx(tx pgx.Tx) BattleReplayRepository { + return &battleReplayRepository{ + q: r.q.WithTx(tx), + c: r.c, + } +} + +func (r *battleReplayRepository) rowToEntity(row sqlc.BattleReplay) *models.BattleReplayEntity { + return &models.BattleReplayEntity{ + ID: convert.UUIDToString(row.ID), + GeometryID: convert.UUIDToString(row.GeometryID), + ProjectID: convert.UUIDToString(row.ProjectID), + TargetGeometryIDs: row.TargetGeometryIds, + Detail: row.Detail, + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } +} + +func (r *battleReplayRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.BattleReplayEntity, error) { + if len(ids) == 0 { + return []*models.BattleReplayEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("battle_replay:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var items []*models.BattleReplayEntity + missingToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.BattleReplayEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetBattleReplaysByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + item := r.rowToEntity(row) + dbMap[item.ID] = item + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var u models.BattleReplayEntity + if err := json.Unmarshal(b, &u); err == nil { + items = append(items, &u) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + items = append(items, item) + missingToCache[keys[i]] = item + } + } + } + + if len(missingToCache) > 0 { + _ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration) + } + + return items, nil +} + +func (r *battleReplayRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.BattleReplayEntity, error) { + return r.getByIDsWithFallback(ctx, ids) +} + +func (r *battleReplayRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.BattleReplayEntity, error) { + cacheId := fmt.Sprintf("battle_replay:id:%s", convert.UUIDToString(id)) + var item models.BattleReplayEntity + err := r.c.Get(ctx, cacheId, &item) + if err == nil { + _ = r.c.Set(ctx, cacheId, item, constants.NormalCacheDuration) + return &item, nil + } + + row, err := r.q.GetBattleReplayById(ctx, id) + if err != nil { + return nil, err + } + + entity := r.rowToEntity(row) + _ = r.c.Set(ctx, cacheId, entity, constants.NormalCacheDuration) + + return entity, nil +} + +func (r *battleReplayRepository) GetByGeometryID(ctx context.Context, geometryID pgtype.UUID) ([]*models.BattleReplayEntity, error) { + cacheKey := fmt.Sprintf("battle_replay:geometry:%s", convert.UUIDToString(geometryID)) + var cachedIDs []string + if err := r.c.Get(ctx, cacheKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetBattleReplaysByGeometryId(ctx, geometryID) + if err != nil { + return nil, err + } + + var items []*models.BattleReplayEntity + var ids []string + itemToCache := make(map[string]any) + + for _, row := range rows { + item := r.rowToEntity(row) + ids = append(ids, item.ID) + items = append(items, item) + itemToCache[fmt.Sprintf("battle_replay:id:%s", item.ID)] = item + } + + if len(itemToCache) > 0 { + _ = r.c.MSet(ctx, itemToCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, cacheKey, ids, constants.ListCacheDuration) + } + + return items, nil +} + +func (r *battleReplayRepository) GetByGeometryIDs(ctx context.Context, geometryIDs []string) ([]*models.BattleReplayEntity, error) { + if len(geometryIDs) == 0 { + return []*models.BattleReplayEntity{}, nil + } + + var pgIds []pgtype.UUID + for _, id := range geometryIDs { + pgId := pgtype.UUID{} + if err := pgId.Scan(id); err == nil { + pgIds = append(pgIds, pgId) + } + } + + rows, err := r.q.GetBattleReplaysByGeometryIDs(ctx, pgIds) + if err != nil { + return nil, err + } + + var items []*models.BattleReplayEntity + itemToCache := make(map[string]any) + + for _, row := range rows { + item := r.rowToEntity(row) + items = append(items, item) + itemToCache[fmt.Sprintf("battle_replay:id:%s", item.ID)] = item + } + + if len(itemToCache) > 0 { + _ = r.c.MSet(ctx, itemToCache, constants.NormalCacheDuration) + } + + return items, nil +} + +func (r *battleReplayRepository) GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.BattleReplayEntity, error) { + cacheKey := fmt.Sprintf("battle_replay:project:%s", convert.UUIDToString(projectID)) + var cachedIDs []string + if err := r.c.Get(ctx, cacheKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetBattleReplaysByProjectId(ctx, projectID) + if err != nil { + return nil, err + } + + var items []*models.BattleReplayEntity + var ids []string + itemToCache := make(map[string]any) + + for _, row := range rows { + item := r.rowToEntity(row) + ids = append(ids, item.ID) + items = append(items, item) + itemToCache[fmt.Sprintf("battle_replay:id:%s", item.ID)] = item + } + + if len(itemToCache) > 0 { + _ = r.c.MSet(ctx, itemToCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, cacheKey, ids, constants.ListCacheDuration) + } + + return items, nil +} + +func (r *battleReplayRepository) Create(ctx context.Context, params sqlc.CreateBattleReplayParams) (*models.BattleReplayEntity, error) { + row, err := r.q.CreateBattleReplay(ctx, params) + if err != nil { + return nil, err + } + + entity := r.rowToEntity(row) + + _ = r.c.Del(ctx, fmt.Sprintf("battle_replay:project:%s", entity.ProjectID)) + _ = r.c.Del(ctx, fmt.Sprintf("battle_replay:geometry:%s", entity.GeometryID)) + + return entity, nil +} + +func (r *battleReplayRepository) Update(ctx context.Context, params sqlc.UpdateBattleReplayParams) (*models.BattleReplayEntity, error) { + row, err := r.q.UpdateBattleReplay(ctx, params) + if err != nil { + return nil, err + } + + entity := r.rowToEntity(row) + _ = r.c.Del(ctx, fmt.Sprintf("battle_replay:id:%s", entity.ID)) + return entity, nil +} + +func (r *battleReplayRepository) Delete(ctx context.Context, id pgtype.UUID) error { + err := r.q.DeleteBattleReplay(ctx, id) + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("battle_replay:id:%s", convert.UUIDToString(id))) + return nil +} + +func (r *battleReplayRepository) DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error { + err := r.q.DeleteBattleReplaysByIDs(ctx, ids) + if err != nil { + return err + } + if len(ids) > 0 { + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("battle_replay:id:%s", convert.UUIDToString(id)) + } + _ = r.c.Del(ctx, keys...) + } + return nil +} diff --git a/internal/routes/battleReplayRoute.go b/internal/routes/battleReplayRoute.go new file mode 100644 index 0000000..0f8a606 --- /dev/null +++ b/internal/routes/battleReplayRoute.go @@ -0,0 +1,13 @@ +package routes + +import ( + "history-api/internal/controllers" + + "github.com/gofiber/fiber/v3" +) + +func BattleReplayRoutes(router fiber.Router, battleReplayController *controllers.BattleReplayController) { + br := router.Group("/battle-replays") + br.Get("/geometry/:geometryId", battleReplayController.GetBattleReplaysByGeometryId) + br.Get("/:id", battleReplayController.GetBattleReplayById) +} diff --git a/internal/services/battleReplayService.go b/internal/services/battleReplayService.go new file mode 100644 index 0000000..1a4931c --- /dev/null +++ b/internal/services/battleReplayService.go @@ -0,0 +1,54 @@ +package services + +import ( + "context" + "history-api/internal/dtos/response" + "history-api/internal/models" + "history-api/internal/repositories" + "history-api/pkg/convert" + + "github.com/gofiber/fiber/v3" +) + +type BattleReplayService interface { + GetByID(ctx context.Context, id string) (*response.BattleReplayResponse, *fiber.Error) + GetByGeometryID(ctx context.Context, geometryID string) ([]*response.BattleReplayResponse, *fiber.Error) +} + +type battleReplayService struct { + battleReplayRepo repositories.BattleReplayRepository +} + +func NewBattleReplayService(battleReplayRepo repositories.BattleReplayRepository) BattleReplayService { + return &battleReplayService{ + battleReplayRepo: battleReplayRepo, + } +} + +func (s *battleReplayService) GetByID(ctx context.Context, id string) (*response.BattleReplayResponse, *fiber.Error) { + replayUUID, err := convert.StringToUUID(id) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid battle replay ID format") + } + + replay, err := s.battleReplayRepo.GetByID(ctx, replayUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Battle replay not found") + } + + return replay.ToResponse(), nil +} + +func (s *battleReplayService) GetByGeometryID(ctx context.Context, geometryID string) ([]*response.BattleReplayResponse, *fiber.Error) { + geomUUID, err := convert.StringToUUID(geometryID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid geometry ID format") + } + + replays, err := s.battleReplayRepo.GetByGeometryID(ctx, geomUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get battle replays") + } + + return models.BattleReplaysEntityToResponse(replays), nil +} diff --git a/internal/services/submissionService.go b/internal/services/submissionService.go index 79e7c5a..8c9da85 100644 --- a/internal/services/submissionService.go +++ b/internal/services/submissionService.go @@ -32,17 +32,18 @@ type SubmissionService interface { } type submissionService struct { - submissionRepo repositories.SubmissionRepository - projectRepo repositories.ProjectRepository - commitRepo repositories.CommitRepository - userRepo repositories.UserRepository - wikiRepo repositories.WikiRepository - geometryRepo repositories.GeometryRepository - entityRepo repositories.EntityRepository - ragRepo repositories.RagRepository - ragUtils *ai.RagUtils - db *pgxpool.Pool - c cache.Cache + submissionRepo repositories.SubmissionRepository + projectRepo repositories.ProjectRepository + commitRepo repositories.CommitRepository + userRepo repositories.UserRepository + wikiRepo repositories.WikiRepository + geometryRepo repositories.GeometryRepository + entityRepo repositories.EntityRepository + battleReplayRepo repositories.BattleReplayRepository + ragRepo repositories.RagRepository + ragUtils *ai.RagUtils + db *pgxpool.Pool + c cache.Cache } func NewSubmissionService( @@ -53,23 +54,25 @@ func NewSubmissionService( wikiRepo repositories.WikiRepository, geometryRepo repositories.GeometryRepository, entityRepo repositories.EntityRepository, + battleReplayRepo repositories.BattleReplayRepository, ragRepo repositories.RagRepository, ragUtils *ai.RagUtils, db *pgxpool.Pool, c cache.Cache, ) SubmissionService { return &submissionService{ - submissionRepo: submissionRepo, - projectRepo: projectRepo, - commitRepo: commitRepo, - userRepo: userRepo, - wikiRepo: wikiRepo, - geometryRepo: geometryRepo, - entityRepo: entityRepo, - ragRepo: ragRepo, - ragUtils: ragUtils, - db: db, - c: c, + submissionRepo: submissionRepo, + projectRepo: projectRepo, + commitRepo: commitRepo, + userRepo: userRepo, + wikiRepo: wikiRepo, + geometryRepo: geometryRepo, + entityRepo: entityRepo, + battleReplayRepo: battleReplayRepo, + ragRepo: ragRepo, + ragUtils: ragUtils, + db: db, + c: c, } } @@ -187,6 +190,7 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer entityRepo := s.entityRepo.WithTx(tx) geometryRepo := s.geometryRepo.WithTx(tx) wikiRepo := s.wikiRepo.WithTx(tx) + battleReplayRepo := s.battleReplayRepo.WithTx(tx) submissionUUID, err := convert.StringToUUID(submissionID) if err != nil { @@ -229,6 +233,7 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer listDeleteEntities := make([]pgtype.UUID, 0) listDeleteWikis := make([]pgtype.UUID, 0) listDeleteGeometries := make([]pgtype.UUID, 0) + listDeleteBattleReplays := make([]pgtype.UUID, 0) var snapshotData request.CommitSnapshot err = json.Unmarshal(commit.SnapshotJson, &snapshotData) if err != nil { @@ -242,17 +247,22 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer } currentEntity, err := s.entityRepo.GetByProjectID(ctx, projectUUID) if err != nil { - return nil, fiber.NewError(fiber.StatusNotFound, "Entity not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Entity not found: "+err.Error()) } currentGeometry, err := s.geometryRepo.GetByProjectID(ctx, projectUUID) if err != nil { - return nil, fiber.NewError(fiber.StatusNotFound, "Geometry not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Geometry not found: "+err.Error()) } currentWiki, err := s.wikiRepo.GetByProjectID(ctx, projectUUID) if err != nil { - return nil, fiber.NewError(fiber.StatusNotFound, "Wiki not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Wiki not found: "+err.Error()) + } + + currentBattleReplay, err := s.battleReplayRepo.GetByProjectID(ctx, projectUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Battle replay not found: "+err.Error()) } persistItemIDs := make(map[string]struct{}) @@ -265,6 +275,9 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer for _, item := range snapshotData.Wikis { persistItemIDs[item.ID] = struct{}{} } + for _, item := range snapshotData.Replays { + persistItemIDs[item.ID] = struct{}{} + } persistCurrentItemIDs := make(map[string]struct{}) for _, item := range currentEntity { @@ -276,6 +289,9 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer for _, item := range currentWiki { persistCurrentItemIDs[item.ID] = struct{}{} } + for _, item := range currentBattleReplay { + persistCurrentItemIDs[item.ID] = struct{}{} + } for _, e := range currentEntity { if _, ok := persistItemIDs[e.ID]; !ok { @@ -310,6 +326,17 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer } } + for _, br := range currentBattleReplay { + if _, ok := persistItemIDs[br.ID]; !ok { + itemUUID, err := convert.StringToUUID(br.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid battle replay ID") + } + listDeleteBattleReplays = append(listDeleteBattleReplays, itemUUID) + delete(persistCurrentItemIDs, br.ID) + } + } + if len(listDeleteEntities) > 0 { if err = entityRepo.DeleteByIDs(ctx, listDeleteEntities); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete entities") @@ -328,6 +355,12 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer } } + if len(listDeleteBattleReplays) > 0 { + if err = battleReplayRepo.DeleteByIDs(ctx, listDeleteBattleReplays); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete battle replays") + } + } + refEntityIDs := []string{} for _, e := range snapshotData.Entities { if e.Source == "ref" { @@ -570,6 +603,46 @@ func (s *submissionService) UpdateSubmissionStatus(ctx context.Context, reviewer } snapshotData.Wikis = newWikis + for _, replay := range snapshotData.Replays { + replayUUID, err := convert.StringToUUID(replay.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid battle replay ID") + } + + geomUUID, err := convert.StringToUUID(replay.GeometryID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid geometry ID in battle replay") + } + + targetIDs, err := json.Marshal(replay.TargetGeometryIDs) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal target geometry IDs") + } + + if _, ok := persistCurrentItemIDs[replay.ID]; ok { + _, err := battleReplayRepo.Update(ctx, sqlc.UpdateBattleReplayParams{ + ID: replayUUID, + GeometryID: geomUUID, + TargetGeometryIds: targetIDs, + Detail: replay.Detail, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update battle replay: "+err.Error()) + } + } else { + _, err := battleReplayRepo.Create(ctx, sqlc.CreateBattleReplayParams{ + ID: replayUUID, + GeometryID: geomUUID, + ProjectID: projectUUID, + TargetGeometryIds: targetIDs, + Detail: replay.Detail, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create battle replay: "+err.Error()) + } + } + } + err = geometryRepo.DeleteEntityGeometriesByProjectID(ctx, projectUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete geometry entity: "+err.Error())