diff --git a/.github/actions/set-env/action.yml b/.github/actions/set-env/action.yml index 94ab829..7d4a131 100644 --- a/.github/actions/set-env/action.yml +++ b/.github/actions/set-env/action.yml @@ -68,7 +68,9 @@ runs: # 対象を増減するときはこの DEPLOY_TARGETS を編集する。 DEPLOY_TARGETS=" academic-api + admin-api announcement-api + app-api user-api build-class-change-notifications-job dispatch-notifications-job diff --git a/Dockerfile b/Dockerfile index f895507..4f21b65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ set -eux; \ mkdir -p /out/bin; \ - for cmd in academic-api announcement-api apply-table-privileges build-class-change-notifications-job dispatch-notifications-job migrate user-api; do \ + for cmd in academic-api admin-api announcement-api app-api apply-table-privileges build-class-change-notifications-job dispatch-notifications-job migrate user-api; do \ CGO_ENABLED=0 GOOS=linux \ go build -tags timetzdata -trimpath -ldflags='-s -w' \ -o /out/bin/${cmd} ./cmd/${cmd}; \ @@ -29,6 +29,8 @@ COPY --from=builder /out/bin/ /bin/ # cmd/migrate は相対パス "migrations" で SQL を読むため、runtime image にも同梱する。 # WORKDIR は distroless のデフォルト "/" を前提に、"migrations" → "/migrations" に解決される。 COPY --from=builder /src/migrations/ /migrations/ +# cmd/admin-api は相対パス "api/openapi/admin/openapi.yaml" で仕様を読むため、同様に同梱する。 +COPY --from=builder /src/api/openapi/ /api/openapi/ USER nonroot:nonroot diff --git a/cmd/admin-api/main.go b/cmd/admin-api/main.go new file mode 100644 index 0000000..53219c1 --- /dev/null +++ b/cmd/admin-api/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "log" + + firebase "firebase.google.com/go/v4" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/handler" + "github.com/fun-dotto/server/internal/modules/admin/middleware" + "github.com/fun-dotto/server/internal/shared/apiclient" + "github.com/fun-dotto/server/internal/shared/server" + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + ginmiddleware "github.com/oapi-codegen/gin-middleware" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not found: %v", err) + } + + ctx := context.Background() + app, err := firebase.NewApp(ctx, nil) + if err != nil { + log.Fatalf("Failed to initialize Firebase App: %v", err) + } + authClient, err := app.Auth(ctx) + if err != nil { + log.Fatalf("Failed to get Firebase Auth client: %v", err) + } + + // 仕様の原本は api/openapi/admin/openapi.yaml だけに置く。runtime image にも + // 同梱しているため、WORKDIR "/" 基準でこの相対パスに解決される。 + spec, err := openapi3.NewLoader().LoadFromFile("api/openapi/admin/openapi.yaml") + if err != nil { + log.Fatalf("Failed to load OpenAPI spec: %v", err) + } + + spec.Servers = nil + + router := gin.Default() + + router.Use(ginmiddleware.OapiRequestValidatorWithOptions(spec, &ginmiddleware.Options{ + ErrorHandler: func(c *gin.Context, message string, statusCode int) { + if authStatusCode, authMessage, ok := middleware.GetAuthenticationError(c); ok { + c.AbortWithStatusJSON(authStatusCode, gin.H{"error": authMessage}) + return + } + c.AbortWithStatusJSON(statusCode, gin.H{"error": message}) + }, + Options: openapi3filter.Options{ + AuthenticationFunc: middleware.FirebaseAuthenticationFunc(authClient), + }, + })) + + clients, err := apiclient.NewExternalClients(ctx) + if err != nil { + log.Fatalf("Failed to initialize external clients: %v", err) + } + + h := handler.NewHandler(clients.Academic, clients.Announcement, clients.Funch, clients.User) + api.RegisterHandlers(router, h) + + if err := server.Run(router, ":8080"); err != nil { + log.Fatalf("Server exited with error: %v", err) + } +} diff --git a/cmd/app-api/main.go b/cmd/app-api/main.go new file mode 100644 index 0000000..ba82344 --- /dev/null +++ b/cmd/app-api/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "log" + + firebaseAdmin "firebase.google.com/go/v4" + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/handler" + "github.com/fun-dotto/server/internal/modules/app/middleware" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/fun-dotto/server/internal/shared/apiclient" + "github.com/fun-dotto/server/internal/shared/server" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not found: %v", err) + } + + ctx := context.Background() + + // Firebase App Check の初期化 + app, err := firebaseAdmin.NewApp(ctx, nil) + if err != nil { + log.Fatalf("error initializing Firebase app: %v\n", err) + } + + appCheckClient, err := app.AppCheck(ctx) + if err != nil { + log.Fatalf("error initializing App Check client: %v\n", err) + } + + authClient, err := app.Auth(ctx) + if err != nil { + log.Fatalf("error initializing Auth client: %v\n", err) + } + + router := gin.Default() + + // 外部APIクライアントを初期化 + clients, err := apiclient.NewExternalClients(ctx) + if err != nil { + log.Fatalf("Failed to create external clients: %v", err) + } + + announcementRepository := repository.NewAnnouncementRepository(clients.Announcement) + announcementService := service.NewAnnouncementService(announcementRepository) + + academicRepository := repository.NewAcademicRepository(clients.Academic) + academicService := service.NewAcademicService(academicRepository) + + userRepository := repository.NewUserRepository(clients.User) + userService := service.NewUserService(userRepository) + + funchRepository := repository.NewFunchRepository(clients.Funch) + funchService := service.NewFunchService(funchRepository) + + h := handler.NewHandler( + handler.WithAnnouncementService(announcementService), + handler.WithAcademicService(academicService), + handler.WithUserService(userService), + handler.WithFunchService(funchService), + ) + + strictHandler := api.NewStrictHandler(h, nil) + api.RegisterHandlersWithOptions(router, strictHandler, api.GinServerOptions{ + Middlewares: []api.MiddlewareFunc{ + api.MiddlewareFunc(middleware.AppCheckMiddleware(appCheckClient)), + api.MiddlewareFunc(middleware.AuthMiddleware(authClient)), + }, + }) + + if err := server.Run(router, ":8080"); err != nil { + log.Fatalf("Server exited with error: %v", err) + } +} diff --git a/go.mod b/go.mod index 21a1148..1ae122b 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/runtime v1.4.0 github.com/stretchr/testify v1.11.1 + google.golang.org/api v0.276.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -141,7 +142,6 @@ require ( golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect - google.golang.org/api v0.276.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect diff --git a/internal/modules/admin/handler/announcement.go b/internal/modules/admin/handler/announcement.go new file mode 100644 index 0000000..66f974c --- /dev/null +++ b/internal/modules/admin/handler/announcement.go @@ -0,0 +1,122 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + announcement_api "github.com/fun-dotto/server/gen/announcement" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// AnnouncementsV1List 一覧を取得する +func (h *Handler) AnnouncementsV1List(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.announcementClient.AnnouncementsV1ListWithResponse(c.Request.Context(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// AnnouncementsV1Detail 詳細を取得する +func (h *Handler) AnnouncementsV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.announcementClient.AnnouncementsV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// AnnouncementsV1Create 新規作成する +func (h *Handler) AnnouncementsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req announcement_api.AnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.announcementClient.AnnouncementsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// AnnouncementsV1Delete 削除する +func (h *Handler) AnnouncementsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.announcementClient.AnnouncementsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} + +// AnnouncementsV1Update 更新する +func (h *Handler) AnnouncementsV1Update(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req announcement_api.AnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.announcementClient.AnnouncementsV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} diff --git a/internal/modules/admin/handler/cancelled_class.go b/internal/modules/admin/handler/cancelled_class.go new file mode 100644 index 0000000..436aadc --- /dev/null +++ b/internal/modules/admin/handler/cancelled_class.go @@ -0,0 +1,81 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// CancelledClassesV1List 休講一覧を取得する +func (h *Handler) CancelledClassesV1List(c *gin.Context, params api.CancelledClassesV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CancelledClassesV1ListWithResponse(c.Request.Context(), &academic_api.CancelledClassesV1ListParams{ + SubjectIds: params.SubjectIds, + From: params.From, + Until: params.Until, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// CancelledClassesV1Create 休講を作成する +func (h *Handler) CancelledClassesV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.CancelledClassRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.CancelledClassesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// CancelledClassesV1Delete 休講を削除する +func (h *Handler) CancelledClassesV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CancelledClassesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/course_registration.go b/internal/modules/admin/handler/course_registration.go new file mode 100644 index 0000000..7391de9 --- /dev/null +++ b/internal/modules/admin/handler/course_registration.go @@ -0,0 +1,85 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// CourseRegistrationsV1List 履修情報を取得する +func (h *Handler) CourseRegistrationsV1List(c *gin.Context, params api.CourseRegistrationsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CourseRegistrationsV1ListWithResponse(c.Request.Context(), &academic_api.CourseRegistrationsV1ListParams{ + UserId: params.UserId, + Year: params.Year, + Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "registrations": response.JSON200.CourseRegistrations, + }) +} + +// CourseRegistrationsV1Create 履修情報を作成する +func (h *Handler) CourseRegistrationsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.CourseRegistrationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.CourseRegistrationsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "registration": response.JSON201.CourseRegistration, + }) +} + +// CourseRegistrationsV1Delete 履修情報を削除する +func (h *Handler) CourseRegistrationsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CourseRegistrationsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/course_registration_test.go b/internal/modules/admin/handler/course_registration_test.go new file mode 100644 index 0000000..da90d2d --- /dev/null +++ b/internal/modules/admin/handler/course_registration_test.go @@ -0,0 +1,56 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/gin-gonic/gin" +) + +func TestCourseRegistrationsV1Create_ProxiesAcademicAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotMethod string + var gotPath string + var gotBody academic_api.CourseRegistrationRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"courseRegistration":{"id":"reg-1","userId":"user-1","subject":{"id":"subject-1","name":"Algorithms","faculties":[]}}}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest( + http.MethodPost, + "/v1/courseRegistrations", + bytes.NewBufferString(`{"subjectId":"subject-1","userId":"user-1"}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + setAdminClaim(c) + + h.CourseRegistrationsV1Create(c) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } + if gotMethod != http.MethodPost || gotPath != "/v1/courseRegistrations" { + t.Fatalf("method/path = %s %s", gotMethod, gotPath) + } + if gotBody.SubjectId != "subject-1" || gotBody.UserId != "user-1" { + t.Fatalf("unexpected upstream request body: %+v", gotBody) + } +} diff --git a/internal/modules/admin/handler/faculty.go b/internal/modules/admin/handler/faculty.go new file mode 100644 index 0000000..7d25cd5 --- /dev/null +++ b/internal/modules/admin/handler/faculty.go @@ -0,0 +1,125 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// FacultiesV1List 教員一覧を取得する +func (h *Handler) FacultiesV1List(c *gin.Context, params api.FacultiesV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.FacultiesV1ListWithResponse(c.Request.Context(), &academic_api.FacultiesV1ListParams{ + Q: params.Q, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// FacultiesV1Detail 教員を詳細取得する +func (h *Handler) FacultiesV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.FacultiesV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// FacultiesV1Create 教員を作成する +func (h *Handler) FacultiesV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.FacultyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.FacultiesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// FacultiesV1Update 教員を更新する +func (h *Handler) FacultiesV1Update(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.FacultyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.FacultiesV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// FacultiesV1Delete 教員を削除する +func (h *Handler) FacultiesV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.FacultiesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/faculty_room.go b/internal/modules/admin/handler/faculty_room.go new file mode 100644 index 0000000..99a95d0 --- /dev/null +++ b/internal/modules/admin/handler/faculty_room.go @@ -0,0 +1,79 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// FacultyRoomsV1List 教員室割当一覧を取得する +func (h *Handler) FacultyRoomsV1List(c *gin.Context, params api.FacultyRoomsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.FacultyRoomsV1ListWithResponse(c.Request.Context(), &academic_api.FacultyRoomsV1ListParams{ + Year: params.Year, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// FacultyRoomsV1Create 教員室割当を作成する +func (h *Handler) FacultyRoomsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.FacultyRoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.FacultyRoomsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// FacultyRoomsV1Delete 教員室割当を削除する +func (h *Handler) FacultyRoomsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.FacultyRoomsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/fcm_token.go b/internal/modules/admin/handler/fcm_token.go new file mode 100644 index 0000000..5374023 --- /dev/null +++ b/internal/modules/admin/handler/fcm_token.go @@ -0,0 +1,62 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + api "github.com/fun-dotto/server/gen/admin" + user_api "github.com/fun-dotto/server/gen/user" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// FCMTokenV1List FCMトークン一覧を取得する +func (h *Handler) FCMTokenV1List(c *gin.Context, params api.FCMTokenV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.FCMTokenV1ListWithResponse(c.Request.Context(), &user_api.FCMTokenV1ListParams{ + UserIds: params.UserIds, + Tokens: params.Tokens, + UpdatedAtFrom: params.UpdatedAtFrom, + UpdatedAtTo: params.UpdatedAtTo, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// FCMTokenV1Upsert FCMトークンを作成または更新する +func (h *Handler) FCMTokenV1Upsert(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req user_api.FCMTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.userClient.FCMTokenV1UpsertWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} diff --git a/internal/modules/admin/handler/handler.go b/internal/modules/admin/handler/handler.go new file mode 100644 index 0000000..c29fbaa --- /dev/null +++ b/internal/modules/admin/handler/handler.go @@ -0,0 +1,44 @@ +package handler + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + announcement_api "github.com/fun-dotto/server/gen/announcement" + funch_api "github.com/fun-dotto/server/gen/funch" + user_api "github.com/fun-dotto/server/gen/user" +) + +type Handler struct { + academicClient *academic_api.ClientWithResponses + announcementClient *announcement_api.ClientWithResponses + funchClient *funch_api.ClientWithResponses + userClient *user_api.ClientWithResponses +} + +func NewHandler( + academicClient *academic_api.ClientWithResponses, + announcementClient *announcement_api.ClientWithResponses, + funchClient *funch_api.ClientWithResponses, + userClient *user_api.ClientWithResponses, +) *Handler { + if academicClient == nil { + panic("academicClient is required") + } + if announcementClient == nil { + panic("announcementClient is required") + } + if funchClient == nil { + panic("funchClient is required") + } + if userClient == nil { + panic("userClient is required") + } + return &Handler{ + academicClient: academicClient, + announcementClient: announcementClient, + funchClient: funchClient, + userClient: userClient, + } +} + +var _ api.ServerInterface = (*Handler)(nil) diff --git a/internal/modules/admin/handler/makeup_class.go b/internal/modules/admin/handler/makeup_class.go new file mode 100644 index 0000000..e3dc621 --- /dev/null +++ b/internal/modules/admin/handler/makeup_class.go @@ -0,0 +1,81 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// MakeupClassesV1List 補講一覧を取得する +func (h *Handler) MakeupClassesV1List(c *gin.Context, params api.MakeupClassesV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.MakeupClassesV1ListWithResponse(c.Request.Context(), &academic_api.MakeupClassesV1ListParams{ + SubjectIds: params.SubjectIds, + From: params.From, + Until: params.Until, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// MakeupClassesV1Create 補講を作成する +func (h *Handler) MakeupClassesV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.MakeupClassRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.MakeupClassesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// MakeupClassesV1Delete 補講を削除する +func (h *Handler) MakeupClassesV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.MakeupClassesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/menu_item.go b/internal/modules/admin/handler/menu_item.go new file mode 100644 index 0000000..d542fa6 --- /dev/null +++ b/internal/modules/admin/handler/menu_item.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + api "github.com/fun-dotto/server/gen/admin" + funch_api "github.com/fun-dotto/server/gen/funch" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// MenuItemsV1List メニュー一覧を取得する +func (h *Handler) MenuItemsV1List(c *gin.Context, params api.MenuItemsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.funchClient.MenuItemsV1ListWithResponse(c.Request.Context(), &funch_api.MenuItemsV1ListParams{ + Date: params.Date, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} diff --git a/internal/modules/admin/handler/notification.go b/internal/modules/admin/handler/notification.go new file mode 100644 index 0000000..960d162 --- /dev/null +++ b/internal/modules/admin/handler/notification.go @@ -0,0 +1,133 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + api "github.com/fun-dotto/server/gen/admin" + user_api "github.com/fun-dotto/server/gen/user" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// NotificationV1List 通知一覧を取得する +func (h *Handler) NotificationV1List(c *gin.Context, params api.NotificationV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.NotificationV1ListWithResponse(c.Request.Context(), &user_api.NotificationV1ListParams{ + NotifyAtFrom: params.NotifyAtFrom, + NotifyAtTo: params.NotifyAtTo, + IsNotified: params.IsNotified, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// NotificationV1Create 通知を作成する +func (h *Handler) NotificationV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req user_api.NotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.userClient.NotificationV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// NotificationV1Update 通知を更新する +func (h *Handler) NotificationV1Update(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req user_api.NotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.userClient.NotificationV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// NotificationV1Dispatch 通知を即時配信する +func (h *Handler) NotificationV1Dispatch(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req user_api.NotificationV1DispatchJSONRequestBody + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.userClient.NotificationV1DispatchWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// NotificationV1Delete 通知を削除する +func (h *Handler) NotificationV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.NotificationV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/personal_calendar_item.go b/internal/modules/admin/handler/personal_calendar_item.go new file mode 100644 index 0000000..9bb5297 --- /dev/null +++ b/internal/modules/admin/handler/personal_calendar_item.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// PersonalCalendarItemsV1List 個人カレンダーアイテム一覧を取得する +func (h *Handler) PersonalCalendarItemsV1List(c *gin.Context, params api.PersonalCalendarItemsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.PersonalCalendarItemsV1ListWithResponse(c.Request.Context(), &academic_api.PersonalCalendarItemsV1ListParams{ + UserId: params.UserId, + Dates: params.Dates, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} diff --git a/internal/modules/admin/handler/personal_calendar_item_test.go b/internal/modules/admin/handler/personal_calendar_item_test.go new file mode 100644 index 0000000..053601d --- /dev/null +++ b/internal/modules/admin/handler/personal_calendar_item_test.go @@ -0,0 +1,59 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + api "github.com/fun-dotto/server/gen/admin" + "github.com/gin-gonic/gin" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +func TestPersonalCalendarItemsV1List_ProxiesAcademicAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + wantDate := openapi_types.Date{Time: time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC)} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query.Get("userId"); got != "user-1" { + t.Fatalf("userId = %q, want %q", got, "user-1") + } + if got := query.Get("dates"); got == "" { + t.Fatal("dates query parameter is empty") + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"personalCalendarItems":[{"date":"2026-03-26","slot":{"dayOfWeek":"Thursday","period":"first"},"timetableItem":{"id":"item-1","subject":{"id":"subject-1","name":"Algorithms"},"slot":{"dayOfWeek":"Thursday","period":"first"},"rooms":[]}}]}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/personalCalendarItems", nil) + setAdminClaim(c) + + h.PersonalCalendarItemsV1List(c, api.PersonalCalendarItemsV1ListParams{ + UserId: "user-1", + Dates: []openapi_types.Date{wantDate}, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body struct { + PersonalCalendarItems []struct { + Date string `json:"date"` + } `json:"personalCalendarItems"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(body.PersonalCalendarItems) != 1 || body.PersonalCalendarItems[0].Date != "2026-03-26" { + t.Fatalf("unexpected response body: %s", rec.Body.String()) + } +} diff --git a/internal/modules/admin/handler/reservation.go b/internal/modules/admin/handler/reservation.go new file mode 100644 index 0000000..a0c89ab --- /dev/null +++ b/internal/modules/admin/handler/reservation.go @@ -0,0 +1,81 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// ReservationsV1List 教室の予約一覧を取得する +func (h *Handler) ReservationsV1List(c *gin.Context, params api.ReservationsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.ReservationsV1ListWithResponse(c.Request.Context(), &academic_api.ReservationsV1ListParams{ + RoomIds: params.RoomIds, + From: params.From, + Until: params.Until, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// ReservationsV1Create 教室を予約する +func (h *Handler) ReservationsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.ReservationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.ReservationsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// ReservationsV1Delete 予約を削除する +func (h *Handler) ReservationsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.ReservationsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/room.go b/internal/modules/admin/handler/room.go new file mode 100644 index 0000000..ef913b5 --- /dev/null +++ b/internal/modules/admin/handler/room.go @@ -0,0 +1,126 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// RoomsV1List 教室一覧を取得する +func (h *Handler) RoomsV1List(c *gin.Context, params api.RoomsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1ListWithResponse(c.Request.Context(), &academic_api.RoomsV1ListParams{ + Q: params.Q, + Floors: convertSlicePtr[api.DottoFoundationV1Floor, academic_api.DottoFoundationV1Floor](params.Floors), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// RoomsV1Create 教室を作成する +func (h *Handler) RoomsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// RoomsV1Detail 教室を詳細取得する +func (h *Handler) RoomsV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// RoomsV1Update 教室を更新する +func (h *Handler) RoomsV1Update(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomsV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// RoomsV1Delete 教室を削除する +func (h *Handler) RoomsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/room_change.go b/internal/modules/admin/handler/room_change.go new file mode 100644 index 0000000..6e05975 --- /dev/null +++ b/internal/modules/admin/handler/room_change.go @@ -0,0 +1,81 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// RoomChangesV1List 教室変更一覧を取得する +func (h *Handler) RoomChangesV1List(c *gin.Context, params api.RoomChangesV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomChangesV1ListWithResponse(c.Request.Context(), &academic_api.RoomChangesV1ListParams{ + SubjectIds: params.SubjectIds, + From: params.From, + Until: params.Until, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// RoomChangesV1Create 教室変更を作成する +func (h *Handler) RoomChangesV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomChangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomChangesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) +} + +// RoomChangesV1Delete 教室変更を削除する +func (h *Handler) RoomChangesV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomChangesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/modules/admin/handler/subject.go b/internal/modules/admin/handler/subject.go new file mode 100644 index 0000000..f87dba1 --- /dev/null +++ b/internal/modules/admin/handler/subject.go @@ -0,0 +1,94 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// SubjectsV1List 科目一覧を取得する +func (h *Handler) SubjectsV1List(c *gin.Context, params api.SubjectsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + clientParams := &academic_api.SubjectsV1ListParams{ + Q: params.Q, + Grades: convertSlicePtr[api.DottoFoundationV1Grade, academic_api.DottoFoundationV1Grade](params.Grades), + Courses: convertSlicePtr[api.DottoFoundationV1Course, academic_api.DottoFoundationV1Course](params.Courses), + Classes: convertSlicePtr[api.DottoFoundationV1Class, academic_api.DottoFoundationV1Class](params.Classes), + Classifications: convertSlicePtr[api.DottoFoundationV1SubjectClassification, academic_api.DottoFoundationV1SubjectClassification](params.Classifications), + Year: params.Year, + Semesters: convertSlicePtr[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + RequirementTypes: convertSlicePtr[api.DottoFoundationV1SubjectRequirementType, academic_api.DottoFoundationV1SubjectRequirementType](params.RequirementTypes), + CulturalSubjectCategories: convertSlicePtr[api.DottoFoundationV1CulturalSubjectCategory, academic_api.DottoFoundationV1CulturalSubjectCategory](params.CulturalSubjectCategories), + } + + response, err := h.academicClient.SubjectsV1ListWithResponse(c.Request.Context(), clientParams) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// SubjectsV1Detail 科目を詳細取得する +func (h *Handler) SubjectsV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.SubjectsV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// SubjectsV1Delete 科目を削除する +func (h *Handler) SubjectsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.SubjectsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} + +func convertSlicePtr[From, To ~string](src *[]From) *[]To { + if src == nil { + return nil + } + result := make([]To, len(*src)) + for i, v := range *src { + result[i] = To(v) + } + return &result +} diff --git a/internal/modules/admin/handler/subject_test.go b/internal/modules/admin/handler/subject_test.go new file mode 100644 index 0000000..bef12f3 --- /dev/null +++ b/internal/modules/admin/handler/subject_test.go @@ -0,0 +1,73 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + api "github.com/fun-dotto/server/gen/admin" + "github.com/gin-gonic/gin" +) + +func TestSubjectsV1List_UsesRenamedQueryParameters(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"subjects":[]}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/subjects", nil) + setAdminClaim(c) + + q := "math" + year := 2026 + grades := []api.DottoFoundationV1Grade{api.B1} + courses := []api.DottoFoundationV1Course{api.InformationSystem} + classes := []api.DottoFoundationV1Class{api.A} + classifications := []api.DottoFoundationV1SubjectClassification{api.Cultural} + semesters := []api.DottoFoundationV1CourseSemester{api.Q1, api.Q2} + requirementTypes := []api.DottoFoundationV1SubjectRequirementType{api.Optional} + categories := []api.DottoFoundationV1CulturalSubjectCategory{api.Society} + + h.SubjectsV1List(c, api.SubjectsV1ListParams{ + Q: &q, + Grades: &grades, + Courses: &courses, + Classes: &classes, + Classifications: &classifications, + Year: &year, + Semesters: &semesters, + RequirementTypes: &requirementTypes, + CulturalSubjectCategories: &categories, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if gotQuery.Get("grades") == "" || gotQuery.Get("grade") != "" { + t.Fatalf("query grades = %q, grade = %q", gotQuery.Get("grades"), gotQuery.Get("grade")) + } + if gotQuery.Get("classes") == "" || gotQuery.Get("class") != "" { + t.Fatalf("query classes = %q, class = %q", gotQuery.Get("classes"), gotQuery.Get("class")) + } + if gotQuery.Get("classifications") == "" || gotQuery.Get("classification") != "" { + t.Fatalf("query classifications = %q, classification = %q", gotQuery.Get("classifications"), gotQuery.Get("classification")) + } + if gotQuery.Get("semesters") == "" || gotQuery.Get("semester") != "" { + t.Fatalf("query semesters = %q, semester = %q", gotQuery.Get("semesters"), gotQuery.Get("semester")) + } + if gotQuery.Get("requirementTypes") == "" || gotQuery.Get("requirementType") != "" { + t.Fatalf("query requirementTypes = %q, requirementType = %q", gotQuery.Get("requirementTypes"), gotQuery.Get("requirementType")) + } + if gotQuery.Get("culturalSubjectCategories") == "" || gotQuery.Get("culturalSubjectCategory") != "" { + t.Fatalf("query culturalSubjectCategories = %q, culturalSubjectCategory = %q", gotQuery.Get("culturalSubjectCategories"), gotQuery.Get("culturalSubjectCategory")) + } +} diff --git a/internal/modules/admin/handler/test_helper_test.go b/internal/modules/admin/handler/test_helper_test.go new file mode 100644 index 0000000..0b10e7b --- /dev/null +++ b/internal/modules/admin/handler/test_helper_test.go @@ -0,0 +1,44 @@ +package handler + +import ( + "testing" + + firebaseauth "firebase.google.com/go/v4/auth" + academic_api "github.com/fun-dotto/server/gen/academic" + announcement_api "github.com/fun-dotto/server/gen/announcement" + funch_api "github.com/fun-dotto/server/gen/funch" + user_api "github.com/fun-dotto/server/gen/user" + "github.com/fun-dotto/server/internal/modules/admin/middleware" + "github.com/gin-gonic/gin" +) + +func newTestHandler(t *testing.T, baseURL string) *Handler { + t.Helper() + + academicClient, err := academic_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new academic client: %v", err) + } + announcementClient, err := announcement_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new announcement client: %v", err) + } + funchClient, err := funch_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new funch client: %v", err) + } + userClient, err := user_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new user client: %v", err) + } + + return NewHandler(academicClient, announcementClient, funchClient, userClient) +} + +func setAdminClaim(c *gin.Context) { + c.Set(middleware.FirebaseTokenContextKey, &firebaseauth.Token{ + Claims: map[string]interface{}{ + "admin": true, + }, + }) +} diff --git a/internal/modules/admin/handler/timetable_item.go b/internal/modules/admin/handler/timetable_item.go new file mode 100644 index 0000000..f10fde6 --- /dev/null +++ b/internal/modules/admin/handler/timetable_item.go @@ -0,0 +1,92 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + academic_api "github.com/fun-dotto/server/gen/academic" + api "github.com/fun-dotto/server/gen/admin" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// TimetableItemsV1List 時間割を取得する +func (h *Handler) TimetableItemsV1List(c *gin.Context, params api.TimetableItemsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.TimetableItemsV1ListWithResponse(c.Request.Context(), &academic_api.TimetableItemsV1ListParams{ + Year: params.Year, + Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": response.JSON200.TimetableItems, + }) +} + +// TimetableItemsV1Create 時間割に追加する +func (h *Handler) TimetableItemsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.TimetableItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.TimetableItemsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "item": response.JSON201.TimetableItem, + }) +} + +// TimetableItemsV1Delete 時間割を削除する +func (h *Handler) TimetableItemsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.TimetableItemsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) +} + +func convertSlice[From, To ~string](src []From) []To { + result := make([]To, len(src)) + for i, v := range src { + result[i] = To(v) + } + return result +} diff --git a/internal/modules/admin/handler/user.go b/internal/modules/admin/handler/user.go new file mode 100644 index 0000000..bc342a2 --- /dev/null +++ b/internal/modules/admin/handler/user.go @@ -0,0 +1,76 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + user_api "github.com/fun-dotto/server/gen/user" + "github.com/fun-dotto/server/internal/modules/admin/middleware" +) + +// UsersV1List ユーザー一覧を取得する +func (h *Handler) UsersV1List(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.UsersV1ListWithResponse(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// UsersV1Detail ユーザーを取得する +func (h *Handler) UsersV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.UsersV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} + +// UsersV1Upsert ユーザーを作成または更新する +func (h *Handler) UsersV1Upsert(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req user_api.UserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.userClient.UsersV1UpsertWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) +} diff --git a/internal/modules/admin/handler/user_test.go b/internal/modules/admin/handler/user_test.go new file mode 100644 index 0000000..231d8b9 --- /dev/null +++ b/internal/modules/admin/handler/user_test.go @@ -0,0 +1,49 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestUsersV1Detail_ProxiesUserAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"user":{"id":"user-1","name":"Jane Doe","email":"jane@example.com"}}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/users/user-1", nil) + setAdminClaim(c) + + h.UsersV1Detail(c, "user-1") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if gotPath != "/v1/users/user-1" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/users/user-1") + } + + var body struct { + User struct { + Id string `json:"id"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.User.Id != "user-1" { + t.Fatalf("unexpected response body: %s", rec.Body.String()) + } +} diff --git a/internal/modules/admin/middleware/firebase_auth.go b/internal/modules/admin/middleware/firebase_auth.go new file mode 100644 index 0000000..929d335 --- /dev/null +++ b/internal/modules/admin/middleware/firebase_auth.go @@ -0,0 +1,218 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + + "firebase.google.com/go/v4/auth" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/gin-gonic/gin" + ginmiddleware "github.com/oapi-codegen/gin-middleware" +) + +type firebaseTokenKey struct{} +type authErrorStatusKey struct{} +type authErrorMessageKey struct{} + +// FirebaseTokenContextKey は Gin の context および context.Context に +// Firebase ID トークンの検証結果を格納するキーです。 +var FirebaseTokenContextKey = firebaseTokenKey{} + +var ( + authenticationErrorStatusKey = authErrorStatusKey{} + authenticationErrorMessageKey = authErrorMessageKey{} +) + +type AuthenticationError struct { + StatusCode int + Message string +} + +func (e *AuthenticationError) Error() string { + return e.Message +} + +// FirebaseAuthenticationFunc は OpenAPI validator 向けの AuthenticationFunc を返します。 +// 認証に成功した場合は検証済みトークンを Gin / request context に格納します。 +func FirebaseAuthenticationFunc(authClient *auth.Client) openapi3filter.AuthenticationFunc { + return func(ctx context.Context, _ *openapi3filter.AuthenticationInput) error { + ginCtx := ginmiddleware.GetGinContext(ctx) + if ginCtx == nil { + return &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "Authentication context is unavailable", + } + } + + token, err := verifyFirebaseToken(ginCtx.GetHeader("Authorization"), ginCtx.Request.Context(), authClient) + if err != nil { + var authErr *AuthenticationError + if errors.As(err, &authErr) { + ginCtx.Set(authenticationErrorStatusKey, authErr.StatusCode) + ginCtx.Set(authenticationErrorMessageKey, authErr.Message) + } + return err + } + + setFirebaseToken(ginCtx, token) + return nil + } +} + +// FirebaseAuth は Authorization: Bearer を検証する Gin ミドルウェアです。 +// 検証に成功すると、デコードされたトークン(*auth.Token)を context に格納して次のハンドラに渡します。 +func FirebaseAuth(authClient *auth.Client) gin.HandlerFunc { + return func(c *gin.Context) { + token, err := verifyFirebaseToken(c.GetHeader("Authorization"), c.Request.Context(), authClient) + if err != nil { + var authErr *AuthenticationError + if errors.As(err, &authErr) { + c.AbortWithStatusJSON(authErr.StatusCode, gin.H{"error": authErr.Message}) + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + + setFirebaseToken(c, token) + c.Next() + } +} + +func verifyFirebaseToken(authHeader string, ctx context.Context, authClient *auth.Client) (*auth.Token, error) { + if authHeader == "" { + return nil, &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "Authorization header is required", + } + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return nil, &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "Authorization header must be Bearer ", + } + } + + idToken := strings.TrimSpace(parts[1]) + if idToken == "" { + return nil, &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "ID token is required", + } + } + + token, err := authClient.VerifyIDToken(ctx, idToken) + if err != nil { + status, message := authErrorResponse(err) + return nil, &AuthenticationError{ + StatusCode: status, + Message: message, + } + } + + return token, nil +} + +func setFirebaseToken(c *gin.Context, token *auth.Token) { + ctx := context.WithValue(c.Request.Context(), FirebaseTokenContextKey, token) + c.Set(FirebaseTokenContextKey, token) + c.Request = c.Request.WithContext(ctx) +} + +// GetAuthenticationError は validator の AuthenticationFunc が格納した認証失敗情報を返します。 +func GetAuthenticationError(c *gin.Context) (int, string, bool) { + status, statusExists := c.Get(authenticationErrorStatusKey) + message, messageExists := c.Get(authenticationErrorMessageKey) + if !statusExists || !messageExists { + return 0, "", false + } + + statusCode, ok := status.(int) + if !ok { + return 0, "", false + } + errorMessage, ok := message.(string) + if !ok { + return 0, "", false + } + + return statusCode, errorMessage, true +} + +// authErrorResponse は Firebase Auth の検証エラーから HTTP ステータスとメッセージを返します。 +func authErrorResponse(err error) (int, string) { + if auth.IsIDTokenExpired(err) { + return http.StatusUnauthorized, "ID token has expired" + } + if auth.IsIDTokenInvalid(err) { + return http.StatusUnauthorized, "Invalid ID token" + } + if auth.IsIDTokenRevoked(err) { + return http.StatusUnauthorized, "ID token has been revoked" + } + if auth.IsUserDisabled(err) { + return http.StatusForbidden, "User has been disabled" + } + return http.StatusUnauthorized, "Authentication failed" +} + +// GetFirebaseToken は Gin の context から検証済みの Firebase トークンを取得します。 +// FirebaseAuth ミドルウェア通過後のハンドラ内でのみ有効です。 +func GetFirebaseToken(c *gin.Context) (*auth.Token, bool) { + val, exists := c.Get(FirebaseTokenContextKey) + if !exists { + return nil, false + } + token, ok := val.(*auth.Token) + return token, ok +} + +// GetFirebaseUID は Gin の context から認証済みユーザーの UID を取得します。 +// トークンが存在しない場合は空文字列を返します。 +func GetFirebaseUID(c *gin.Context) string { + token, ok := GetFirebaseToken(c) + if !ok || token == nil { + return "" + } + return token.UID +} + +// GetFirebaseTokenFromContext は context.Context から Firebase トークンを取得します。 +// ミドルウェアで c.Request に設定した context から取得する場合に使用します。 +func GetFirebaseTokenFromContext(ctx context.Context) (*auth.Token, bool) { + val := ctx.Value(FirebaseTokenContextKey) + if val == nil { + return nil, false + } + token, ok := val.(*auth.Token) + return token, ok +} + +// RequireAnyClaim は指定したカスタムクレームのいずれかが true であることを検証します。 +// いずれのクレームも満たさない場合は 403 レスポンスを返し false を返します。 +func RequireAnyClaim(c *gin.Context, claims ...string) bool { + token, ok := GetFirebaseToken(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Authentication required", + }) + return false + } + + for _, claim := range claims { + if val, exists := token.Claims[claim]; exists { + if boolVal, ok := val.(bool); ok && boolVal { + return true + } + } + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "Insufficient permissions", + }) + return false +} diff --git a/internal/modules/app/domain/announcement.go b/internal/modules/app/domain/announcement.go new file mode 100644 index 0000000..e70b619 --- /dev/null +++ b/internal/modules/app/domain/announcement.go @@ -0,0 +1,18 @@ +package domain + +import "time" + +type Announcement struct { + ID string + Title string + AvailableFrom time.Time + AvailableUntil *time.Time + URL string +} + +type AnnouncementRequest struct { + Title string + AvailableFrom time.Time + AvailableUntil *time.Time + URL string +} diff --git a/internal/modules/app/domain/announcement_query.go b/internal/modules/app/domain/announcement_query.go new file mode 100644 index 0000000..4d9777a --- /dev/null +++ b/internal/modules/app/domain/announcement_query.go @@ -0,0 +1,6 @@ +package domain + +type AnnouncementQuery struct { + SortByDate *SortDirection + FilterIsActive *bool +} diff --git a/internal/modules/app/domain/cancelled_class.go b/internal/modules/app/domain/cancelled_class.go new file mode 100644 index 0000000..7ac1dcf --- /dev/null +++ b/internal/modules/app/domain/cancelled_class.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +// CancelledClass 休講 +type CancelledClass struct { + ID string + Comment string + Date time.Time + Period Period + Subject Subject +} + +// CancelledClassQuery 休講検索クエリ +type CancelledClassQuery struct { + SubjectIDs *[]string + From *time.Time + Until *time.Time +} diff --git a/internal/modules/app/domain/course_registration.go b/internal/modules/app/domain/course_registration.go new file mode 100644 index 0000000..52b4562 --- /dev/null +++ b/internal/modules/app/domain/course_registration.go @@ -0,0 +1,7 @@ +package domain + +// CourseRegistration 履修登録 +type CourseRegistration struct { + ID string + Subject Subject +} diff --git a/internal/modules/app/domain/makeup_class.go b/internal/modules/app/domain/makeup_class.go new file mode 100644 index 0000000..ea00198 --- /dev/null +++ b/internal/modules/app/domain/makeup_class.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +// MakeupClass 補講 +type MakeupClass struct { + ID string + Comment string + Date time.Time + Period Period + Subject Subject +} + +// MakeupClassQuery 補講検索クエリ +type MakeupClassQuery struct { + SubjectIDs *[]string + From *time.Time + Until *time.Time +} diff --git a/internal/modules/app/domain/menu_item.go b/internal/modules/app/domain/menu_item.go new file mode 100644 index 0000000..9e3407d --- /dev/null +++ b/internal/modules/app/domain/menu_item.go @@ -0,0 +1,44 @@ +package domain + +import "time" + +// MenuCategory メニュー分類 +type MenuCategory string + +const ( + MenuCategorySetAndSingle MenuCategory = "SetAndSingle" + MenuCategoryBowlAndCurry MenuCategory = "BowlAndCurry" + MenuCategoryNoodle MenuCategory = "Noodle" + MenuCategorySide MenuCategory = "Side" + MenuCategoryDessert MenuCategory = "Dessert" +) + +// MenuSize メニューサイズ +type MenuSize string + +const ( + MenuSizeSmall MenuSize = "Small" + MenuSizeMedium MenuSize = "Medium" + MenuSizeLarge MenuSize = "Large" +) + +// MenuPrice メニュー価格 +type MenuPrice struct { + Size MenuSize + Price int32 +} + +// MenuItem メニュー項目 +type MenuItem struct { + ID string + Date time.Time + Name string + ImageURL string + Category MenuCategory + Prices []MenuPrice +} + +// MenuItemQuery メニュー検索クエリ +type MenuItemQuery struct { + Date time.Time +} diff --git a/internal/modules/app/domain/personal_calendar_item.go b/internal/modules/app/domain/personal_calendar_item.go new file mode 100644 index 0000000..9be2cfd --- /dev/null +++ b/internal/modules/app/domain/personal_calendar_item.go @@ -0,0 +1,22 @@ +package domain + +import "time" + +// PersonalCalendarItem 個人カレンダーアイテム +type PersonalCalendarItem struct { + Date time.Time + Period Period + Rooms []Room + Status PersonalCalendarItemStatus + Subject Subject +} + +// PersonalCalendarItemStatus 個人カレンダーアイテムの状態 +type PersonalCalendarItemStatus string + +const ( + PersonalCalendarItemStatusNormal PersonalCalendarItemStatus = "Normal" + PersonalCalendarItemStatusCancelled PersonalCalendarItemStatus = "Cancelled" + PersonalCalendarItemStatusMakeup PersonalCalendarItemStatus = "Makeup" + PersonalCalendarItemStatusRoomChanged PersonalCalendarItemStatus = "RoomChanged" +) diff --git a/internal/modules/app/domain/reservation.go b/internal/modules/app/domain/reservation.go new file mode 100644 index 0000000..233e568 --- /dev/null +++ b/internal/modules/app/domain/reservation.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +// Reservation 教室の予約 +type Reservation struct { + ID string + Room Room + StartAt time.Time + EndAt time.Time + Title string +} + +// ReservationQuery 予約検索クエリ +type ReservationQuery struct { + RoomIDs []string + From *time.Time + Until *time.Time +} diff --git a/internal/modules/app/domain/room_change.go b/internal/modules/app/domain/room_change.go new file mode 100644 index 0000000..90b52e4 --- /dev/null +++ b/internal/modules/app/domain/room_change.go @@ -0,0 +1,20 @@ +package domain + +import "time" + +// RoomChange 教室変更 +type RoomChange struct { + ID string + Date time.Time + Period Period + Subject Subject + OriginalRoom Room + NewRoom Room +} + +// RoomChangeQuery 教室変更検索クエリ +type RoomChangeQuery struct { + SubjectIDs *[]string + From *time.Time + Until *time.Time +} diff --git a/internal/modules/app/domain/sort_direction.go b/internal/modules/app/domain/sort_direction.go new file mode 100644 index 0000000..2d0f5ce --- /dev/null +++ b/internal/modules/app/domain/sort_direction.go @@ -0,0 +1,8 @@ +package domain + +const ( + SortDirectionAsc SortDirection = "asc" + SortDirectionDesc SortDirection = "desc" +) + +type SortDirection string diff --git a/internal/modules/app/domain/subject.go b/internal/modules/app/domain/subject.go new file mode 100644 index 0000000..ac63222 --- /dev/null +++ b/internal/modules/app/domain/subject.go @@ -0,0 +1,170 @@ +package domain + +// Subject 科目 +type Subject struct { + ID string + Name string + Year int + Credit int + Semester CourseSemester + Faculties []SubjectFaculty + Requirements []SubjectRequirement + EligibleAttributes []SubjectTargetClass + Syllabus *Syllabus +} + +// SubjectFaculty 科目担当教員 +type SubjectFaculty struct { + Faculty Faculty + IsPrimary bool +} + +// Faculty 教員 +type Faculty struct { + ID string + Name string + Email string +} + +// SubjectRequirement 科目の必修/選択区分 +type SubjectRequirement struct { + Course Course + RequirementType SubjectRequirementType +} + +// SubjectTargetClass 対象学年・クラス +type SubjectTargetClass struct { + Grade Grade + Class *Class +} + +// Syllabus シラバス +type Syllabus struct { + ID string + Name string + EnName string + Summary string + LearningOutcomes string + ContentsAndSchedule string + PreLearning string + PostLearning string + Assignments string + EvaluationMethod string + Textbooks string + ReferenceBooks string + Prerequisites string + Notes string + Keywords string + Classifications string + Grades string + Credit int + FacultyNames string + TeachingForm string + TeachingAndExamForm string + TeachingLanguage string + MultiplePersonTeachingForm string + PracticalHomeFacultyCategory string + DsopSubject string + TargetAreas string + TargetCourses string +} + +// CourseSemester 開講時期 +type CourseSemester string + +const ( + CourseSemesterQ1 CourseSemester = "Q1" + CourseSemesterQ2 CourseSemester = "Q2" + CourseSemesterQ3 CourseSemester = "Q3" + CourseSemesterQ4 CourseSemester = "Q4" + CourseSemesterH1 CourseSemester = "H1" + CourseSemesterH2 CourseSemester = "H2" + CourseSemesterAllYear CourseSemester = "AllYear" + CourseSemesterSummerIntensive CourseSemester = "SummerIntensive" + CourseSemesterWinterIntensive CourseSemester = "WinterIntensive" +) + +// Grade 学年 +type Grade string + +const ( + GradeB1 Grade = "B1" + GradeB2 Grade = "B2" + GradeB3 Grade = "B3" + GradeB4 Grade = "B4" + GradeM1 Grade = "M1" + GradeM2 Grade = "M2" + GradeD1 Grade = "D1" + GradeD2 Grade = "D2" + GradeD3 Grade = "D3" +) + +// Class クラス +type Class string + +const ( + ClassA Class = "A" + ClassB Class = "B" + ClassC Class = "C" + ClassD Class = "D" + ClassE Class = "E" + ClassF Class = "F" + ClassG Class = "G" + ClassH Class = "H" + ClassI Class = "I" + ClassJ Class = "J" + ClassK Class = "K" + ClassL Class = "L" +) + +// Course コース +type Course string + +const ( + CourseInformationSystem Course = "InformationSystem" // 情報システムコース + CourseInformationDesign Course = "InformationDesign" // 情報デザインコース + CourseComplexSystem Course = "ComplexSystem" // 複雑系コース + CourseIntelligentSystem Course = "IntelligentSystem" // 知能システムコース + CourseAdvancedICT Course = "AdvancedICT" // 高度ICTコース +) + +// SubjectRequirementType 必修・選択区分 +type SubjectRequirementType string + +const ( + SubjectRequirementTypeRequired SubjectRequirementType = "Required" // 必修 + SubjectRequirementTypeOptional SubjectRequirementType = "Optional" // 選択 + SubjectRequirementTypeOptionalRequired SubjectRequirementType = "OptionalRequired" // 選択必修 +) + +// SubjectClassification 科目分類 +type SubjectClassification string + +const ( + SubjectClassificationSpecialized SubjectClassification = "Specialized" // 専門科目 + SubjectClassificationCultural SubjectClassification = "Cultural" // 教養科目 + SubjectClassificationResearchInstruction SubjectClassification = "ResearchInstruction" // 研究指導科目 +) + +// CulturalSubjectCategory 教養科目カテゴリ +type CulturalSubjectCategory string + +const ( + CulturalSubjectCategoryHuman CulturalSubjectCategory = "Human" // 人間の形成 + CulturalSubjectCategorySociety CulturalSubjectCategory = "Society" // 社会への参加 + CulturalSubjectCategoryScience CulturalSubjectCategory = "Science" // 科学技術と環境の理解 + CulturalSubjectCategoryHealth CulturalSubjectCategory = "Health" // 健康の保持 + CulturalSubjectCategoryCommunication CulturalSubjectCategory = "Communication" // コミュニケーション +) + +// SubjectQuery 科目検索クエリ +type SubjectQuery struct { + Q string + Grade []Grade + Courses []Course + Class []Class + Classification []SubjectClassification + Semester []CourseSemester + RequirementType []SubjectRequirementType + CulturalSubjectCategory []CulturalSubjectCategory +} diff --git a/internal/modules/app/domain/timetable_item.go b/internal/modules/app/domain/timetable_item.go new file mode 100644 index 0000000..77eb044 --- /dev/null +++ b/internal/modules/app/domain/timetable_item.go @@ -0,0 +1,67 @@ +package domain + +// TimetableItem 時間割アイテム +type TimetableItem struct { + ID string + Slot *TimetableSlot + Rooms []Room + Subject Subject +} + +// DayOfWeek 曜日 +type DayOfWeek string + +const ( + DayOfWeekMonday DayOfWeek = "Monday" + DayOfWeekTuesday DayOfWeek = "Tuesday" + DayOfWeekWednesday DayOfWeek = "Wednesday" + DayOfWeekThursday DayOfWeek = "Thursday" + DayOfWeekFriday DayOfWeek = "Friday" + DayOfWeekSaturday DayOfWeek = "Saturday" + DayOfWeekSunday DayOfWeek = "Sunday" +) + +// Period 時限 +type Period string + +const ( + PeriodPeriod1 Period = "Period1" + PeriodPeriod2 Period = "Period2" + PeriodPeriod3 Period = "Period3" + PeriodPeriod4 Period = "Period4" + PeriodPeriod5 Period = "Period5" + PeriodPeriod6 Period = "Period6" +) + +// Floor 教室の階数 +type Floor string + +const ( + Floor1 Floor = "Floor1" + Floor2 Floor = "Floor2" + Floor3 Floor = "Floor3" + Floor4 Floor = "Floor4" + Floor5 Floor = "Floor5" + Floor6 Floor = "Floor6" + Floor7 Floor = "Floor7" + FloorVirtual Floor = "Virtual" +) + +// TimetableSlot 時間割の曜日・時限 +type TimetableSlot struct { + DayOfWeek DayOfWeek + Period Period +} + +// Room 教室 +type Room struct { + ID string + Name string + Floor Floor +} + +// TimetableItemQuery 時間割アイテム検索クエリ +type TimetableItemQuery struct { + Semesters []CourseSemester + Year *int +} diff --git a/internal/modules/app/domain/user.go b/internal/modules/app/domain/user.go new file mode 100644 index 0000000..61ddd05 --- /dev/null +++ b/internal/modules/app/domain/user.go @@ -0,0 +1,38 @@ +package domain + +import ( + "errors" + "time" +) + +// ErrUserNotFound ユーザーが見つからない +var ErrUserNotFound = errors.New("user not found") + +// User ユーザー +type User struct { + ID string + Email string + Grade *Grade + Course *Course + Class *Class +} + +// UserRequest ユーザー作成・更新リクエスト +type UserRequest struct { + Email string + Grade *Grade + Course *Course + Class *Class +} + +// FCMToken FCMトークン +type FCMToken struct { + Token string + CreatedAt time.Time + UpdatedAt time.Time +} + +// FCMTokenRequest FCMトークン作成・更新リクエスト +type FCMTokenRequest struct { + Token string +} diff --git a/internal/modules/app/external/announcement_model.go b/internal/modules/app/external/announcement_model.go new file mode 100644 index 0000000..9e87725 --- /dev/null +++ b/internal/modules/app/external/announcement_model.go @@ -0,0 +1,25 @@ +package external + +import ( + announcement_api "github.com/fun-dotto/server/gen/announcement" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +func ToDomainAnnouncement(m announcement_api.Announcement) domain.Announcement { + return domain.Announcement{ + ID: m.Id, + Title: m.Title, + AvailableFrom: m.AvailableFrom, + AvailableUntil: m.AvailableUntil, + URL: m.Url, + } +} + +func ToExternalAnnouncementRequest(r domain.AnnouncementRequest) announcement_api.AnnouncementRequest { + return announcement_api.AnnouncementRequest{ + Title: r.Title, + AvailableFrom: r.AvailableFrom, + AvailableUntil: r.AvailableUntil, + Url: r.URL, + } +} diff --git a/internal/modules/app/external/cancelled_class_model.go b/internal/modules/app/external/cancelled_class_model.go new file mode 100644 index 0000000..a83e19c --- /dev/null +++ b/internal/modules/app/external/cancelled_class_model.go @@ -0,0 +1,34 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// ToDomainCancelledClass は外部APIのCancelledClassをDomainに変換する +func ToDomainCancelledClass(m academic_api.CancelledClass) domain.CancelledClass { + return domain.CancelledClass{ + ID: m.Id, + Comment: m.Comment, + Date: m.Date.Time, + Period: domain.Period(m.Period), + Subject: ToDomainSubjectSummary(m.Subject), + } +} + +// ToExternalCancelledClassQuery はDomainのCancelledClassQueryを外部APIのパラメータに変換する +func ToExternalCancelledClassQuery(q domain.CancelledClassQuery) *academic_api.CancelledClassesV1ListParams { + params := &academic_api.CancelledClassesV1ListParams{ + SubjectIds: q.SubjectIDs, + } + if q.From != nil { + d := openapi_types.Date{Time: *q.From} + params.From = &d + } + if q.Until != nil { + d := openapi_types.Date{Time: *q.Until} + params.Until = &d + } + return params +} diff --git a/internal/modules/app/external/course_registration_model.go b/internal/modules/app/external/course_registration_model.go new file mode 100644 index 0000000..31e8b8f --- /dev/null +++ b/internal/modules/app/external/course_registration_model.go @@ -0,0 +1,14 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// ToDomainCourseRegistration は外部APIのCourseRegistrationをDomainのCourseRegistrationに変換する +func ToDomainCourseRegistration(m academic_api.CourseRegistration) domain.CourseRegistration { + return domain.CourseRegistration{ + ID: m.Id, + Subject: ToDomainSubjectSummary(m.Subject), + } +} diff --git a/internal/modules/app/external/faculty_model.go b/internal/modules/app/external/faculty_model.go new file mode 100644 index 0000000..40845d1 --- /dev/null +++ b/internal/modules/app/external/faculty_model.go @@ -0,0 +1,24 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// ToDomainFaculty は外部APIのFacultyをDomainのFacultyに変換する +func ToDomainFaculty(f academic_api.Faculty) domain.Faculty { + return domain.Faculty{ + ID: f.Id, + Name: f.Name, + Email: f.Email, + } +} + +// ToDomainFaculties は外部APIのFaculty一覧をDomainのFaculty一覧に変換する +func ToDomainFaculties(faculties []academic_api.Faculty) []domain.Faculty { + result := make([]domain.Faculty, len(faculties)) + for i, f := range faculties { + result[i] = ToDomainFaculty(f) + } + return result +} diff --git a/internal/modules/app/external/makeup_class_model.go b/internal/modules/app/external/makeup_class_model.go new file mode 100644 index 0000000..e9529d7 --- /dev/null +++ b/internal/modules/app/external/makeup_class_model.go @@ -0,0 +1,34 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// ToDomainMakeupClass は外部APIのMakeupClassをDomainに変換する +func ToDomainMakeupClass(m academic_api.MakeupClass) domain.MakeupClass { + return domain.MakeupClass{ + ID: m.Id, + Comment: m.Comment, + Date: m.Date.Time, + Period: domain.Period(m.Period), + Subject: ToDomainSubjectSummary(m.Subject), + } +} + +// ToExternalMakeupClassQuery はDomainのMakeupClassQueryを外部APIのパラメータに変換する +func ToExternalMakeupClassQuery(q domain.MakeupClassQuery) *academic_api.MakeupClassesV1ListParams { + params := &academic_api.MakeupClassesV1ListParams{ + SubjectIds: q.SubjectIDs, + } + if q.From != nil { + d := openapi_types.Date{Time: *q.From} + params.From = &d + } + if q.Until != nil { + d := openapi_types.Date{Time: *q.Until} + params.Until = &d + } + return params +} diff --git a/internal/modules/app/external/menu_item_model.go b/internal/modules/app/external/menu_item_model.go new file mode 100644 index 0000000..934b283 --- /dev/null +++ b/internal/modules/app/external/menu_item_model.go @@ -0,0 +1,33 @@ +package external + +import ( + funch_api "github.com/fun-dotto/server/gen/funch" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// ToDomainMenuItem は外部APIのMenuItemをDomainに変換する +func ToDomainMenuItem(m funch_api.MenuItem) domain.MenuItem { + prices := make([]domain.MenuPrice, len(m.Prices)) + for i, p := range m.Prices { + prices[i] = domain.MenuPrice{ + Size: domain.MenuSize(p.Size), + Price: p.Price, + } + } + return domain.MenuItem{ + ID: m.Id, + Date: m.Date.Time, + Name: m.Name, + ImageURL: m.ImageUrl, + Category: domain.MenuCategory(m.Category), + Prices: prices, + } +} + +// ToExternalMenuItemQuery はDomainのMenuItemQueryを外部APIのパラメータに変換する +func ToExternalMenuItemQuery(q domain.MenuItemQuery) *funch_api.MenuItemsV1ListParams { + return &funch_api.MenuItemsV1ListParams{ + Date: openapi_types.Date{Time: q.Date}, + } +} diff --git a/internal/modules/app/external/reservation_model.go b/internal/modules/app/external/reservation_model.go new file mode 100644 index 0000000..9835e20 --- /dev/null +++ b/internal/modules/app/external/reservation_model.go @@ -0,0 +1,34 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// ToDomainReservation は外部APIのReservationをDomainに変換する +func ToDomainReservation(m academic_api.Reservation) domain.Reservation { + return domain.Reservation{ + ID: m.Id, + Room: domain.Room{ + ID: m.Room.Id, + Name: m.Room.Name, + Floor: domain.Floor(m.Room.Floor), + }, + StartAt: m.StartAt, + EndAt: m.EndAt, + Title: m.Title, + } +} + +// ToExternalReservationQuery はDomainのReservationQueryを外部APIのパラメータに変換する +func ToExternalReservationQuery(q domain.ReservationQuery) *academic_api.ReservationsV1ListParams { + params := &academic_api.ReservationsV1ListParams{ + From: q.From, + Until: q.Until, + } + if len(q.RoomIDs) > 0 { + ids := q.RoomIDs + params.RoomIds = &ids + } + return params +} diff --git a/internal/modules/app/external/room_change_model.go b/internal/modules/app/external/room_change_model.go new file mode 100644 index 0000000..7605e40 --- /dev/null +++ b/internal/modules/app/external/room_change_model.go @@ -0,0 +1,43 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// ToDomainRoomChange は外部APIのRoomChangeをDomainに変換する +func ToDomainRoomChange(m academic_api.RoomChange) domain.RoomChange { + return domain.RoomChange{ + ID: m.Id, + Date: m.Date.Time, + Period: domain.Period(m.Period), + Subject: ToDomainSubjectSummary(m.Subject), + OriginalRoom: domain.Room{ + ID: m.OriginalRoom.Id, + Name: m.OriginalRoom.Name, + Floor: domain.Floor(m.OriginalRoom.Floor), + }, + NewRoom: domain.Room{ + ID: m.NewRoom.Id, + Name: m.NewRoom.Name, + Floor: domain.Floor(m.NewRoom.Floor), + }, + } +} + +// ToExternalRoomChangeQuery はDomainのRoomChangeQueryを外部APIのパラメータに変換する +func ToExternalRoomChangeQuery(q domain.RoomChangeQuery) *academic_api.RoomChangesV1ListParams { + params := &academic_api.RoomChangesV1ListParams{ + SubjectIds: q.SubjectIDs, + } + if q.From != nil { + d := openapi_types.Date{Time: *q.From} + params.From = &d + } + if q.Until != nil { + d := openapi_types.Date{Time: *q.Until} + params.Until = &d + } + return params +} diff --git a/internal/modules/app/external/subject_model.go b/internal/modules/app/external/subject_model.go new file mode 100644 index 0000000..be9b582 --- /dev/null +++ b/internal/modules/app/external/subject_model.go @@ -0,0 +1,204 @@ +package external + +import ( + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// ToDomainSubject は外部APIのSubjectをDomainのSubjectに変換する +func ToDomainSubject(m academic_api.Subject) domain.Subject { + requirements := make([]academic_api.SubjectRequirement, 0) + if m.Requirements != nil { + requirements = *m.Requirements + } + + eligibleAttributes := make([]academic_api.SubjectTargetClass, 0) + if m.EligibleAttributes != nil { + eligibleAttributes = *m.EligibleAttributes + } + + return domain.Subject{ + ID: m.Id, + Name: m.Name, + Year: m.Year, + Credit: m.Credit, + Semester: domain.CourseSemester(m.Semester), + Faculties: toDomainSubjectFaculties(m.Faculties), + Requirements: toDomainSubjectRequirements(requirements), + EligibleAttributes: toDomainSubjectTargetClasses(eligibleAttributes), + Syllabus: nil, + } +} + +func ToDomainSubjectSummary(m academic_api.Subject) domain.Subject { + return ToDomainSubject(m) +} + +func ToDomainSyllabus(m academic_api.Syllabus) domain.Syllabus { + return domain.Syllabus{ + ID: m.Id, + Name: m.Name, + EnName: m.EnName, + Summary: m.Summary, + LearningOutcomes: m.LearningOutcomes, + ContentsAndSchedule: m.ContentsAndSchedule, + PreLearning: m.PreLearning, + PostLearning: m.PostLearning, + Assignments: m.Assignments, + EvaluationMethod: m.EvaluationMethod, + Textbooks: m.Textbooks, + ReferenceBooks: m.ReferenceBooks, + Prerequisites: m.Prerequisites, + Notes: m.Notes, + Keywords: m.Keywords, + Classifications: m.Classifications, + Grades: m.Grades, + Credit: m.Credit, + FacultyNames: m.FacultyNames, + TeachingForm: m.TeachingForm, + TeachingAndExamForm: m.TeachingAndExamForm, + TeachingLanguage: m.TeachingLanguage, + MultiplePersonTeachingForm: m.MultiplePersonTeachingForm, + PracticalHomeFacultyCategory: m.PracticalHomeFacultyCategory, + DsopSubject: m.DsopSubject, + TargetAreas: m.TargetAreas, + TargetCourses: m.TargetCourses, + } +} + +func toDomainSubjectFaculties(faculties []academic_api.SubjectFaculty) []domain.SubjectFaculty { + result := make([]domain.SubjectFaculty, len(faculties)) + for i, f := range faculties { + result[i] = domain.SubjectFaculty{ + Faculty: domain.Faculty{ + ID: f.Faculty.Id, + Name: f.Faculty.Name, + Email: f.Faculty.Email, + }, + IsPrimary: f.IsPrimary, + } + } + return result +} + +func toDomainSubjectRequirements(requirements []academic_api.SubjectRequirement) []domain.SubjectRequirement { + result := make([]domain.SubjectRequirement, len(requirements)) + for i, r := range requirements { + result[i] = domain.SubjectRequirement{ + Course: domain.Course(r.Course), + RequirementType: domain.SubjectRequirementType(r.RequirementType), + } + } + return result +} + +func toDomainSubjectTargetClasses(targetClasses []academic_api.SubjectTargetClass) []domain.SubjectTargetClass { + result := make([]domain.SubjectTargetClass, len(targetClasses)) + for i, tc := range targetClasses { + var class *domain.Class + if tc.Class != nil { + c := domain.Class(*tc.Class) + class = &c + } + result[i] = domain.SubjectTargetClass{ + Grade: domain.Grade(tc.Grade), + Class: class, + } + } + return result +} + +// ToExternalSubjectQuery はDomainのSubjectQueryを外部APIのSubjectsV1ListParamsに変換する +func ToExternalSubjectQuery(q domain.SubjectQuery) *academic_api.SubjectsV1ListParams { + params := &academic_api.SubjectsV1ListParams{} + + if q.Q != "" { + params.Q = &q.Q + } + if len(q.Grade) > 0 { + grades := toExternalGrades(q.Grade) + params.Grades = &grades + } + if len(q.Courses) > 0 { + courses := toExternalCourses(q.Courses) + params.Courses = &courses + } + if len(q.Class) > 0 { + classes := toExternalClasses(q.Class) + params.Classes = &classes + } + if len(q.Classification) > 0 { + classifications := toExternalClassifications(q.Classification) + params.Classifications = &classifications + } + if len(q.Semester) > 0 { + semesters := toExternalSemesters(q.Semester) + params.Semesters = &semesters + } + if len(q.RequirementType) > 0 { + requirementTypes := toExternalRequirementTypes(q.RequirementType) + params.RequirementTypes = &requirementTypes + } + if len(q.CulturalSubjectCategory) > 0 { + culturalSubjectCategories := toExternalCulturalSubjectCategories(q.CulturalSubjectCategory) + params.CulturalSubjectCategories = &culturalSubjectCategories + } + + return params +} + +func toExternalGrades(grades []domain.Grade) []academic_api.DottoFoundationV1Grade { + result := make([]academic_api.DottoFoundationV1Grade, len(grades)) + for i, g := range grades { + result[i] = academic_api.DottoFoundationV1Grade(g) + } + return result +} + +func toExternalCourses(courses []domain.Course) []academic_api.DottoFoundationV1Course { + result := make([]academic_api.DottoFoundationV1Course, len(courses)) + for i, c := range courses { + result[i] = academic_api.DottoFoundationV1Course(c) + } + return result +} + +func toExternalClasses(classes []domain.Class) []academic_api.DottoFoundationV1Class { + result := make([]academic_api.DottoFoundationV1Class, len(classes)) + for i, c := range classes { + result[i] = academic_api.DottoFoundationV1Class(c) + } + return result +} + +func toExternalClassifications(classifications []domain.SubjectClassification) []academic_api.DottoFoundationV1SubjectClassification { + result := make([]academic_api.DottoFoundationV1SubjectClassification, len(classifications)) + for i, c := range classifications { + result[i] = academic_api.DottoFoundationV1SubjectClassification(c) + } + return result +} + +func toExternalSemesters(semesters []domain.CourseSemester) []academic_api.DottoFoundationV1CourseSemester { + result := make([]academic_api.DottoFoundationV1CourseSemester, len(semesters)) + for i, s := range semesters { + result[i] = academic_api.DottoFoundationV1CourseSemester(s) + } + return result +} + +func toExternalRequirementTypes(types []domain.SubjectRequirementType) []academic_api.DottoFoundationV1SubjectRequirementType { + result := make([]academic_api.DottoFoundationV1SubjectRequirementType, len(types)) + for i, t := range types { + result[i] = academic_api.DottoFoundationV1SubjectRequirementType(t) + } + return result +} + +func toExternalCulturalSubjectCategories(categories []domain.CulturalSubjectCategory) []academic_api.DottoFoundationV1CulturalSubjectCategory { + result := make([]academic_api.DottoFoundationV1CulturalSubjectCategory, len(categories)) + for i, c := range categories { + result[i] = academic_api.DottoFoundationV1CulturalSubjectCategory(c) + } + return result +} diff --git a/internal/modules/app/external/timetable_item_model.go b/internal/modules/app/external/timetable_item_model.go new file mode 100644 index 0000000..58963a7 --- /dev/null +++ b/internal/modules/app/external/timetable_item_model.go @@ -0,0 +1,72 @@ +package external + +import ( + "time" + + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// ToDomainTimetableItem は外部APIのTimetableItemをDomainのTimetableItemに変換する +func ToDomainTimetableItem(m academic_api.TimetableItem) domain.TimetableItem { + var slot *domain.TimetableSlot + if m.Slot != nil { + slot = &domain.TimetableSlot{ + DayOfWeek: domain.DayOfWeek(m.Slot.DayOfWeek), + Period: domain.Period(m.Slot.Period), + } + } + + return domain.TimetableItem{ + ID: m.Id, + Slot: slot, + Rooms: toDomainRooms(m.Rooms), + Subject: ToDomainSubjectSummary(m.Subject), + } +} + +// ToExternalTimetableItemQuery はDomainのTimetableItemQueryを外部APIのTimetableItemsV1ListParamsに変換する +func ToExternalTimetableItemQuery(q domain.TimetableItemQuery) *academic_api.TimetableItemsV1ListParams { + params := &academic_api.TimetableItemsV1ListParams{ + Semesters: toExternalSemesters(q.Semesters), + Year: q.Year, + } + + return params +} + +// ToDomainPersonalCalendarItem は外部APIのPersonalCalendarItemをDomainに変換する +func ToDomainPersonalCalendarItem(m academic_api.PersonalCalendarItem) domain.PersonalCalendarItem { + return domain.PersonalCalendarItem{ + Date: m.Date.Time, + Period: domain.Period(m.Period), + Rooms: toDomainRooms(m.Rooms), + Status: domain.PersonalCalendarItemStatus(m.Status), + Subject: ToDomainSubjectSummary(m.Subject), + } +} + +// ToExternalPersonalCalendarItemParams は domain の検索条件を外部APIのクエリに変換する +func ToExternalPersonalCalendarItemParams(userID string, dates []time.Time) *academic_api.PersonalCalendarItemsV1ListParams { + apiDates := make([]openapi_types.Date, len(dates)) + for i, d := range dates { + apiDates[i] = openapi_types.Date{Time: d} + } + return &academic_api.PersonalCalendarItemsV1ListParams{ + UserId: userID, + Dates: apiDates, + } +} + +func toDomainRooms(rooms []academic_api.Room) []domain.Room { + result := make([]domain.Room, len(rooms)) + for i, room := range rooms { + result[i] = domain.Room{ + ID: room.Id, + Name: room.Name, + Floor: domain.Floor(room.Floor), + } + } + return result +} diff --git a/internal/modules/app/external/user_model.go b/internal/modules/app/external/user_model.go new file mode 100644 index 0000000..0dc0575 --- /dev/null +++ b/internal/modules/app/external/user_model.go @@ -0,0 +1,62 @@ +package external + +import ( + user_api "github.com/fun-dotto/server/gen/user" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// ToDomainUser は外部APIのUserをDomainのUserに変換する +func ToDomainUser(m user_api.User) domain.User { + user := domain.User{ + ID: m.Id, + Email: m.Email, + } + if m.Grade != nil { + g := domain.Grade(*m.Grade) + user.Grade = &g + } + if m.Course != nil { + c := domain.Course(*m.Course) + user.Course = &c + } + if m.Class != nil { + cl := domain.Class(*m.Class) + user.Class = &cl + } + return user +} + +// ToExternalUserRequest はDomainのUserRequestを外部APIのUserRequestに変換する +func ToExternalUserRequest(req domain.UserRequest) user_api.UserRequest { + body := user_api.UserRequest{ + Email: req.Email, + } + if req.Grade != nil { + g := user_api.DottoFoundationV1Grade(*req.Grade) + body.Grade = &g + } + if req.Course != nil { + c := user_api.DottoFoundationV1Course(*req.Course) + body.Course = &c + } + if req.Class != nil { + cl := user_api.DottoFoundationV1Class(*req.Class) + body.Class = &cl + } + return body +} + +func ToDomainFCMToken(m user_api.FCMToken) domain.FCMToken { + return domain.FCMToken{ + Token: m.Token, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func ToExternalFCMTokenRequest(userID string, req domain.FCMTokenRequest) user_api.FCMTokenRequest { + return user_api.FCMTokenRequest{ + Token: req.Token, + UserId: userID, + } +} diff --git a/internal/modules/app/handler/announcement_v1.go b/internal/modules/app/handler/announcement_v1.go new file mode 100644 index 0000000..d1645f9 --- /dev/null +++ b/internal/modules/app/handler/announcement_v1.go @@ -0,0 +1,39 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +func (h *Handler) AnnouncementsV1List(ctx context.Context, request api.AnnouncementsV1ListRequestObject) (api.AnnouncementsV1ListResponseObject, error) { + if h.announcementService == nil { + return nil, errAnnouncementServiceNotConfigured + } + + announcements, err := h.announcementService.GetAnnouncements() + if err != nil { + return nil, fmt.Errorf("failed to get announcements: %w", err) + } + + apiAnnouncements := make([]api.Announcement, len(announcements)) + for i, announcement := range announcements { + apiAnnouncements[i] = toApiAnnouncement(announcement) + } + + return api.AnnouncementsV1List200JSONResponse{ + Announcements: apiAnnouncements, + }, nil +} + +// toApiAnnouncement はDomainのお知らせをAPIのお知らせに変換する +func toApiAnnouncement(announcement domain.Announcement) api.Announcement { + return api.Announcement{ + Id: announcement.ID, + Title: announcement.Title, + Date: announcement.AvailableFrom, + Url: announcement.URL, + } +} diff --git a/internal/modules/app/handler/announcement_v1_test.go b/internal/modules/app/handler/announcement_v1_test.go new file mode 100644 index 0000000..d9fd122 --- /dev/null +++ b/internal/modules/app/handler/announcement_v1_test.go @@ -0,0 +1,54 @@ +package handler + +import ( + "context" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnnouncementsV1List(t *testing.T) { + tests := []struct { + name string + validate func(t *testing.T, resp api.AnnouncementsV1ListResponseObject, err error) + }{ + { + name: "正常にお知らせ一覧が取得できる", + validate: func(t *testing.T, resp api.AnnouncementsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.AnnouncementsV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + assert.NotEmpty(t, result.Announcements, "アナウンスメントが空です") + }, + }, + { + name: "お知らせのフィールドが正しく返される", + validate: func(t *testing.T, resp api.AnnouncementsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.AnnouncementsV1List200JSONResponse) + require.True(t, ok) + assert.Len(t, result.Announcements, 1, "MockRepositoryは1件返すはずです") + assert.Equal(t, "1", result.Announcements[0].Id) + assert.Equal(t, "Announcement 1", result.Announcements[0].Title) + assert.Equal(t, "https://example.com", result.Announcements[0].Url) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockAnnouncementRepository() + h := NewHandler(WithAnnouncementService(service.NewAnnouncementService(mockRepo))) + + resp, err := h.AnnouncementsV1List(context.Background(), api.AnnouncementsV1ListRequestObject{}) + + if tt.validate != nil { + tt.validate(t, resp, err) + } + }) + } +} diff --git a/internal/modules/app/handler/cancelled_class.go b/internal/modules/app/handler/cancelled_class.go new file mode 100644 index 0000000..27fef0e --- /dev/null +++ b/internal/modules/app/handler/cancelled_class.go @@ -0,0 +1,57 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +func (h *Handler) CancelledClassesV1List(ctx context.Context, request api.CancelledClassesV1ListRequestObject) (api.CancelledClassesV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + query := toCancelledClassQuery(request.Params) + + classes, err := h.academicService.GetCancelledClasses(query) + if err != nil { + return nil, fmt.Errorf("failed to get cancelled classes: %w", err) + } + + apiClasses := make([]api.CancelledClass, len(classes)) + for i, c := range classes { + apiClasses[i] = toApiCancelledClass(c) + } + + return api.CancelledClassesV1List200JSONResponse{ + CancelledClasses: apiClasses, + }, nil +} + +func toCancelledClassQuery(params api.CancelledClassesV1ListParams) domain.CancelledClassQuery { + query := domain.CancelledClassQuery{ + SubjectIDs: params.SubjectIds, + } + if params.From != nil { + t := params.From.Time + query.From = &t + } + if params.Until != nil { + t := params.Until.Time + query.Until = &t + } + return query +} + +func toApiCancelledClass(c domain.CancelledClass) api.CancelledClass { + return api.CancelledClass{ + Id: c.ID, + Comment: c.Comment, + Date: openapi_types.Date{Time: c.Date}, + Period: api.DottoFoundationV1Period(c.Period), + Subject: toApiSubjectSummary(c.Subject), + } +} diff --git a/internal/modules/app/handler/cancelled_class_test.go b/internal/modules/app/handler/cancelled_class_test.go new file mode 100644 index 0000000..5ba97bb --- /dev/null +++ b/internal/modules/app/handler/cancelled_class_test.go @@ -0,0 +1,59 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCancelledClassesV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + validate func(t *testing.T, resp api.CancelledClassesV1ListResponseObject, err error) + }{ + { + name: "正常に休講一覧が取得できる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CancelledClassesV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.CancelledClassesV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.Len(t, result.CancelledClasses, 1) + assert.Equal(t, "cancelled-1", result.CancelledClasses[0].Id) + assert.Equal(t, "教員出張のため", result.CancelledClasses[0].Comment) + assert.Equal(t, api.DottoFoundationV1Period("Period1"), result.CancelledClasses[0].Period) + assert.Equal(t, "s1", result.CancelledClasses[0].Subject.Id) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + validate: func(t *testing.T, resp api.CancelledClassesV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getCancelledClasses", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.CancelledClassesV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get cancelled classes") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.CancelledClassesV1List(context.Background(), api.CancelledClassesV1ListRequestObject{}) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/course_registration.go b/internal/modules/app/handler/course_registration.go new file mode 100644 index 0000000..8fb1270 --- /dev/null +++ b/internal/modules/app/handler/course_registration.go @@ -0,0 +1,89 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/middleware" +) + +// CourseRegistrationsV1List 履修登録一覧を取得する +func (h *Handler) CourseRegistrationsV1List(ctx context.Context, request api.CourseRegistrationsV1ListRequestObject) (api.CourseRegistrationsV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.CourseRegistrationsV1List401Response{}, nil + } + + semesters := make([]domain.CourseSemester, len(request.Params.Semesters)) + for i, semester := range request.Params.Semesters { + semesters[i] = domain.CourseSemester(semester) + } + + registrations, err := h.academicService.GetCourseRegistrations(userID, semesters, request.Params.Year) + if err != nil { + return nil, fmt.Errorf("failed to get course registrations: %w", err) + } + + apiRegistrations := make([]api.CourseRegistration, len(registrations)) + for i, r := range registrations { + apiRegistrations[i] = toApiCourseRegistration(r) + } + + return api.CourseRegistrationsV1List200JSONResponse{ + CourseRegistrations: apiRegistrations, + }, nil +} + +// CourseRegistrationsV1Create 履修登録を作成する +func (h *Handler) CourseRegistrationsV1Create(ctx context.Context, request api.CourseRegistrationsV1CreateRequestObject) (api.CourseRegistrationsV1CreateResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.CourseRegistrationsV1Create401Response{}, nil + } + + registration, err := h.academicService.CreateCourseRegistration(userID, request.Body.SubjectId) + if err != nil { + return nil, fmt.Errorf("failed to create course registration: %w", err) + } + + return api.CourseRegistrationsV1Create201JSONResponse{ + CourseRegistration: toApiCourseRegistration(*registration), + }, nil +} + +// CourseRegistrationsV1Delete 履修登録を削除する +func (h *Handler) CourseRegistrationsV1Delete(ctx context.Context, request api.CourseRegistrationsV1DeleteRequestObject) (api.CourseRegistrationsV1DeleteResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.CourseRegistrationsV1Delete401Response{}, nil + } + _ = userID + + err := h.academicService.DeleteCourseRegistration(request.Id) + if err != nil { + return nil, fmt.Errorf("failed to delete course registration: %w", err) + } + + return api.CourseRegistrationsV1Delete204Response{}, nil +} + +func toApiCourseRegistration(r domain.CourseRegistration) api.CourseRegistration { + return api.CourseRegistration{ + Id: r.ID, + Subject: toApiSubjectSummary(r.Subject), + } +} diff --git a/internal/modules/app/handler/course_registration_test.go b/internal/modules/app/handler/course_registration_test.go new file mode 100644 index 0000000..3d1e19e --- /dev/null +++ b/internal/modules/app/handler/course_registration_test.go @@ -0,0 +1,196 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ctxWithUserID(userID string) context.Context { + return context.WithValue(context.Background(), "userID", userID) +} + +func TestCourseRegistrationsV1List(t *testing.T) { + tests := []struct { + name string + ctx context.Context + handler *Handler + validate func(t *testing.T, resp api.CourseRegistrationsV1ListResponseObject, err error) + }{ + { + name: "正常に履修登録一覧が取得できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.CourseRegistrationsV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + assert.Len(t, result.CourseRegistrations, 1) + assert.Equal(t, "cr1", result.CourseRegistrations[0].Id) + assert.Equal(t, "s1", result.CourseRegistrations[0].Subject.Id) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + validate: func(t *testing.T, resp api.CourseRegistrationsV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1ListResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.CourseRegistrationsV1List401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getRegistrations", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get course registrations") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.CourseRegistrationsV1List(tt.ctx, api.CourseRegistrationsV1ListRequestObject{ + Params: api.CourseRegistrationsV1ListParams{ + Semesters: []api.DottoFoundationV1CourseSemester{api.DottoFoundationV1CourseSemester("Q1")}, + }, + }) + tt.validate(t, resp, err) + }) + } +} + +func TestCourseRegistrationsV1Create(t *testing.T) { + tests := []struct { + name string + ctx context.Context + handler *Handler + validate func(t *testing.T, resp api.CourseRegistrationsV1CreateResponseObject, err error) + }{ + { + name: "正常に履修登録を作成できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1CreateResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.CourseRegistrationsV1Create201JSONResponse) + require.True(t, ok, "レスポンスが201 JSONレスポンスではありません") + assert.Equal(t, "cr-new", result.CourseRegistration.Id) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + validate: func(t *testing.T, resp api.CourseRegistrationsV1CreateResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1CreateResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.CourseRegistrationsV1Create401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("create", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1CreateResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create course registration") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.CourseRegistrationsV1Create(tt.ctx, api.CourseRegistrationsV1CreateRequestObject{ + Body: &api.CourseRegistrationsV1CreateJSONRequestBody{ + SubjectId: "s1", + }, + }) + tt.validate(t, resp, err) + }) + } +} + +func TestCourseRegistrationsV1Delete(t *testing.T) { + tests := []struct { + name string + ctx context.Context + handler *Handler + validate func(t *testing.T, resp api.CourseRegistrationsV1DeleteResponseObject, err error) + }{ + { + name: "正常に履修登録を削除できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1DeleteResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.CourseRegistrationsV1Delete204Response) + require.True(t, ok, "レスポンスが204レスポンスではありません") + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + validate: func(t *testing.T, resp api.CourseRegistrationsV1DeleteResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1DeleteResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.CourseRegistrationsV1Delete401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("delete", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.CourseRegistrationsV1DeleteResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete course registration") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.CourseRegistrationsV1Delete(tt.ctx, api.CourseRegistrationsV1DeleteRequestObject{ + Id: "cr1", + }) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/handler.go b/internal/modules/app/handler/handler.go new file mode 100644 index 0000000..c4123bc --- /dev/null +++ b/internal/modules/app/handler/handler.go @@ -0,0 +1,55 @@ +package handler + +import ( + "errors" + + "github.com/fun-dotto/server/internal/modules/app/service" +) + +var ( + errAnnouncementServiceNotConfigured = errors.New("announcementService is not configured") + errAcademicServiceNotConfigured = errors.New("academicService is not configured") + errUserServiceNotConfigured = errors.New("userService is not configured") + errFunchServiceNotConfigured = errors.New("funchService is not configured") +) + +type Handler struct { + announcementService *service.AnnouncementService + academicService *service.AcademicService + userService *service.UserService + funchService *service.FunchService +} + +type Option func(*Handler) + +func WithAnnouncementService(s *service.AnnouncementService) Option { + return func(h *Handler) { + h.announcementService = s + } +} + +func WithAcademicService(s *service.AcademicService) Option { + return func(h *Handler) { + h.academicService = s + } +} + +func WithUserService(s *service.UserService) Option { + return func(h *Handler) { + h.userService = s + } +} + +func WithFunchService(s *service.FunchService) Option { + return func(h *Handler) { + h.funchService = s + } +} + +func NewHandler(opts ...Option) *Handler { + h := &Handler{} + for _, opt := range opts { + opt(h) + } + return h +} diff --git a/internal/modules/app/handler/makeup_class.go b/internal/modules/app/handler/makeup_class.go new file mode 100644 index 0000000..62cdf41 --- /dev/null +++ b/internal/modules/app/handler/makeup_class.go @@ -0,0 +1,57 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +func (h *Handler) MakeupClassesV1List(ctx context.Context, request api.MakeupClassesV1ListRequestObject) (api.MakeupClassesV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + query := toMakeupClassQuery(request.Params) + + classes, err := h.academicService.GetMakeupClasses(query) + if err != nil { + return nil, fmt.Errorf("failed to get makeup classes: %w", err) + } + + apiClasses := make([]api.MakeupClass, len(classes)) + for i, c := range classes { + apiClasses[i] = toApiMakeupClass(c) + } + + return api.MakeupClassesV1List200JSONResponse{ + MakeupClasses: apiClasses, + }, nil +} + +func toMakeupClassQuery(params api.MakeupClassesV1ListParams) domain.MakeupClassQuery { + query := domain.MakeupClassQuery{ + SubjectIDs: params.SubjectIds, + } + if params.From != nil { + t := params.From.Time + query.From = &t + } + if params.Until != nil { + t := params.Until.Time + query.Until = &t + } + return query +} + +func toApiMakeupClass(c domain.MakeupClass) api.MakeupClass { + return api.MakeupClass{ + Id: c.ID, + Comment: c.Comment, + Date: openapi_types.Date{Time: c.Date}, + Period: api.DottoFoundationV1Period(c.Period), + Subject: toApiSubjectSummary(c.Subject), + } +} diff --git a/internal/modules/app/handler/makeup_class_test.go b/internal/modules/app/handler/makeup_class_test.go new file mode 100644 index 0000000..507bab1 --- /dev/null +++ b/internal/modules/app/handler/makeup_class_test.go @@ -0,0 +1,59 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMakeupClassesV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + validate func(t *testing.T, resp api.MakeupClassesV1ListResponseObject, err error) + }{ + { + name: "正常に補講一覧が取得できる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.MakeupClassesV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.MakeupClassesV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.Len(t, result.MakeupClasses, 1) + assert.Equal(t, "makeup-1", result.MakeupClasses[0].Id) + assert.Equal(t, "休講分の補講", result.MakeupClasses[0].Comment) + assert.Equal(t, api.DottoFoundationV1Period("Period2"), result.MakeupClasses[0].Period) + assert.Equal(t, "s1", result.MakeupClasses[0].Subject.Id) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + validate: func(t *testing.T, resp api.MakeupClassesV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getMakeupClasses", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.MakeupClassesV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get makeup classes") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.MakeupClassesV1List(context.Background(), api.MakeupClassesV1ListRequestObject{}) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/menu_item.go b/internal/modules/app/handler/menu_item.go new file mode 100644 index 0000000..7efcc78 --- /dev/null +++ b/internal/modules/app/handler/menu_item.go @@ -0,0 +1,50 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +func (h *Handler) MenuItemsV1List(_ context.Context, request api.MenuItemsV1ListRequestObject) (api.MenuItemsV1ListResponseObject, error) { + if h.funchService == nil { + return nil, errFunchServiceNotConfigured + } + + query := domain.MenuItemQuery{Date: request.Params.Date.Time} + + items, err := h.funchService.GetMenuItems(query) + if err != nil { + return nil, fmt.Errorf("failed to get menu items: %w", err) + } + + apiItems := make([]api.MenuItem, len(items)) + for i, item := range items { + apiItems[i] = toApiMenuItem(item) + } + + return api.MenuItemsV1List200JSONResponse{ + MenuItems: apiItems, + }, nil +} + +func toApiMenuItem(m domain.MenuItem) api.MenuItem { + prices := make([]api.Price, len(m.Prices)) + for i, p := range m.Prices { + prices[i] = api.Price{ + Size: api.Size(p.Size), + Price: p.Price, + } + } + return api.MenuItem{ + Id: m.ID, + Date: openapi_types.Date{Time: m.Date}, + Name: m.Name, + ImageUrl: m.ImageURL, + Category: api.Category(m.Category), + Prices: prices, + } +} diff --git a/internal/modules/app/handler/menu_item_test.go b/internal/modules/app/handler/menu_item_test.go new file mode 100644 index 0000000..f7cd39c --- /dev/null +++ b/internal/modules/app/handler/menu_item_test.go @@ -0,0 +1,61 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMenuItemsV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + validate func(t *testing.T, resp api.MenuItemsV1ListResponseObject, err error) + }{ + { + name: "正常にメニュー一覧が取得できる", + handler: NewHandler(WithFunchService(service.NewFunchService(repository.NewMockFunchRepository()))), + validate: func(t *testing.T, resp api.MenuItemsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.MenuItemsV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.Len(t, result.MenuItems, 1) + assert.Equal(t, "menu-1", result.MenuItems[0].Id) + assert.Equal(t, "からあげ定食", result.MenuItems[0].Name) + assert.Equal(t, api.Category("SetAndSingle"), result.MenuItems[0].Category) + require.Len(t, result.MenuItems[0].Prices, 1) + assert.Equal(t, api.Size("Medium"), result.MenuItems[0].Prices[0].Size) + assert.Equal(t, int32(500), result.MenuItems[0].Prices[0].Price) + }, + }, + { + name: "funchServiceが未設定の場合エラーを返す", + handler: NewHandler(), + validate: func(t *testing.T, resp api.MenuItemsV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errFunchServiceNotConfigured) + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithFunchService(service.NewFunchService(repository.NewMockFunchRepositoryWithError("getMenuItems", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.MenuItemsV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get menu items") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.MenuItemsV1List(context.Background(), api.MenuItemsV1ListRequestObject{}) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/personal_calendar_item.go b/internal/modules/app/handler/personal_calendar_item.go new file mode 100644 index 0000000..b099045 --- /dev/null +++ b/internal/modules/app/handler/personal_calendar_item.go @@ -0,0 +1,53 @@ +package handler + +import ( + "context" + "fmt" + "time" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/middleware" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// PersonalCalendarItemsV1List 個人カレンダーアイテム一覧を取得する +func (h *Handler) PersonalCalendarItemsV1List(ctx context.Context, request api.PersonalCalendarItemsV1ListRequestObject) (api.PersonalCalendarItemsV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.PersonalCalendarItemsV1List401Response{}, nil + } + + dates := make([]time.Time, len(request.Params.Dates)) + for i, d := range request.Params.Dates { + dates[i] = d.Time + } + + items, err := h.academicService.GetPersonalCalendarItems(userID, dates) + if err != nil { + return nil, fmt.Errorf("failed to get personal calendar items: %w", err) + } + + apiItems := make([]api.PersonalCalendarItem, len(items)) + for i, item := range items { + apiItems[i] = toAPIPersonalCalendarItem(item) + } + + return api.PersonalCalendarItemsV1List200JSONResponse{ + PersonalCalendarItems: apiItems, + }, nil +} + +func toAPIPersonalCalendarItem(item domain.PersonalCalendarItem) api.PersonalCalendarItem { + return api.PersonalCalendarItem{ + Date: openapi_types.Date{Time: item.Date}, + Period: api.DottoFoundationV1Period(item.Period), + Rooms: toApiRooms(item.Rooms), + Status: api.DottoFoundationV1PersonalCalendarItemStatus(item.Status), + Subject: toApiSubjectSummary(item.Subject), + } +} diff --git a/internal/modules/app/handler/personal_calendar_item_test.go b/internal/modules/app/handler/personal_calendar_item_test.go new file mode 100644 index 0000000..52a60d7 --- /dev/null +++ b/internal/modules/app/handler/personal_calendar_item_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "fmt" + "testing" + "time" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersonalCalendarItemsV1List(t *testing.T) { + dates := []openapi_types.Date{{Time: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)}} + + tests := []struct { + name string + ctx context.Context + handler *Handler + validate func(t *testing.T, resp api.PersonalCalendarItemsV1ListResponseObject, err error) + }{ + { + name: "正常に個人カレンダーアイテム一覧を取得できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.PersonalCalendarItemsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.PersonalCalendarItemsV1List200JSONResponse) + require.True(t, ok) + assert.Len(t, result.PersonalCalendarItems, 1) + assert.Equal(t, api.DottoFoundationV1Period("Period1"), result.PersonalCalendarItems[0].Period) + assert.Equal(t, "s1", result.PersonalCalendarItems[0].Subject.Id) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + validate: func(t *testing.T, resp api.PersonalCalendarItemsV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.PersonalCalendarItemsV1ListResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.PersonalCalendarItemsV1List401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getPersonalCalendarItems", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.PersonalCalendarItemsV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get personal calendar items") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.PersonalCalendarItemsV1List(tt.ctx, api.PersonalCalendarItemsV1ListRequestObject{ + Params: api.PersonalCalendarItemsV1ListParams{ + Dates: dates, + }, + }) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/reservation.go b/internal/modules/app/handler/reservation.go new file mode 100644 index 0000000..c42b0ff --- /dev/null +++ b/internal/modules/app/handler/reservation.go @@ -0,0 +1,56 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +func (h *Handler) ReservationsV1List(_ context.Context, request api.ReservationsV1ListRequestObject) (api.ReservationsV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + query := toReservationQuery(request.Params) + + reservations, err := h.academicService.GetReservations(query) + if err != nil { + return nil, fmt.Errorf("failed to get reservations: %w", err) + } + + apiReservations := make([]api.Reservation, len(reservations)) + for i, r := range reservations { + apiReservations[i] = toApiReservation(r) + } + + return api.ReservationsV1List200JSONResponse{ + Reservations: apiReservations, + }, nil +} + +func toReservationQuery(params api.ReservationsV1ListParams) domain.ReservationQuery { + query := domain.ReservationQuery{ + From: params.From, + Until: params.Until, + } + if params.RoomIds != nil { + query.RoomIDs = *params.RoomIds + } + return query +} + +func toApiReservation(r domain.Reservation) api.Reservation { + return api.Reservation{ + Id: r.ID, + Room: api.Room{ + Id: r.Room.ID, + Name: r.Room.Name, + Floor: api.DottoFoundationV1Floor(r.Room.Floor), + }, + StartAt: r.StartAt, + EndAt: r.EndAt, + Title: r.Title, + } +} diff --git a/internal/modules/app/handler/reservation_test.go b/internal/modules/app/handler/reservation_test.go new file mode 100644 index 0000000..219f734 --- /dev/null +++ b/internal/modules/app/handler/reservation_test.go @@ -0,0 +1,59 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReservationsV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + validate func(t *testing.T, resp api.ReservationsV1ListResponseObject, err error) + }{ + { + name: "正常に予約一覧が取得できる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.ReservationsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.ReservationsV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.Len(t, result.Reservations, 1) + assert.Equal(t, "reservation-1", result.Reservations[0].Id) + assert.Equal(t, "ゼミ", result.Reservations[0].Title) + assert.Equal(t, "r1", result.Reservations[0].Room.Id) + assert.Equal(t, api.DottoFoundationV1Floor("Floor1"), result.Reservations[0].Room.Floor) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + validate: func(t *testing.T, resp api.ReservationsV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getReservations", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.ReservationsV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get reservations") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.ReservationsV1List(context.Background(), api.ReservationsV1ListRequestObject{}) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/room_change.go b/internal/modules/app/handler/room_change.go new file mode 100644 index 0000000..b53a39e --- /dev/null +++ b/internal/modules/app/handler/room_change.go @@ -0,0 +1,66 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +func (h *Handler) RoomChangesV1List(ctx context.Context, request api.RoomChangesV1ListRequestObject) (api.RoomChangesV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + query := toRoomChangeQuery(request.Params) + + changes, err := h.academicService.GetRoomChanges(query) + if err != nil { + return nil, fmt.Errorf("failed to get room changes: %w", err) + } + + apiChanges := make([]api.RoomChange, len(changes)) + for i, c := range changes { + apiChanges[i] = toApiRoomChange(c) + } + + return api.RoomChangesV1List200JSONResponse{ + RoomChanges: apiChanges, + }, nil +} + +func toRoomChangeQuery(params api.RoomChangesV1ListParams) domain.RoomChangeQuery { + query := domain.RoomChangeQuery{ + SubjectIDs: params.SubjectIds, + } + if params.From != nil { + t := params.From.Time + query.From = &t + } + if params.Until != nil { + t := params.Until.Time + query.Until = &t + } + return query +} + +func toApiRoomChange(c domain.RoomChange) api.RoomChange { + return api.RoomChange{ + Id: c.ID, + Date: openapi_types.Date{Time: c.Date}, + Period: api.DottoFoundationV1Period(c.Period), + Subject: toApiSubjectSummary(c.Subject), + OriginalRoom: api.Room{ + Id: c.OriginalRoom.ID, + Name: c.OriginalRoom.Name, + Floor: api.DottoFoundationV1Floor(c.OriginalRoom.Floor), + }, + NewRoom: api.Room{ + Id: c.NewRoom.ID, + Name: c.NewRoom.Name, + Floor: api.DottoFoundationV1Floor(c.NewRoom.Floor), + }, + } +} diff --git a/internal/modules/app/handler/room_change_test.go b/internal/modules/app/handler/room_change_test.go new file mode 100644 index 0000000..44f3834 --- /dev/null +++ b/internal/modules/app/handler/room_change_test.go @@ -0,0 +1,64 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRoomChangesV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + validate func(t *testing.T, resp api.RoomChangesV1ListResponseObject, err error) + }{ + { + name: "正常に教室変更一覧が取得できる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.RoomChangesV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.RoomChangesV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.Len(t, result.RoomChanges, 1) + assert.Equal(t, "room-change-1", result.RoomChanges[0].Id) + assert.Equal(t, api.DottoFoundationV1Period("Period3"), result.RoomChanges[0].Period) + assert.Equal(t, "s1", result.RoomChanges[0].Subject.Id) + assert.Equal(t, "r1", result.RoomChanges[0].OriginalRoom.Id) + assert.Equal(t, "101講義室", result.RoomChanges[0].OriginalRoom.Name) + assert.Equal(t, api.DottoFoundationV1Floor("Floor1"), result.RoomChanges[0].OriginalRoom.Floor) + assert.Equal(t, "r2", result.RoomChanges[0].NewRoom.Id) + assert.Equal(t, "201講義室", result.RoomChanges[0].NewRoom.Name) + assert.Equal(t, api.DottoFoundationV1Floor("Floor2"), result.RoomChanges[0].NewRoom.Floor) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + validate: func(t *testing.T, resp api.RoomChangesV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getRoomChanges", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.RoomChangesV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get room changes") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.RoomChangesV1List(context.Background(), api.RoomChangesV1ListRequestObject{}) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/stub.go b/internal/modules/app/handler/stub.go new file mode 100644 index 0000000..abeebd1 --- /dev/null +++ b/internal/modules/app/handler/stub.go @@ -0,0 +1 @@ +package handler diff --git a/internal/modules/app/handler/subject.go b/internal/modules/app/handler/subject.go new file mode 100644 index 0000000..1b5ef46 --- /dev/null +++ b/internal/modules/app/handler/subject.go @@ -0,0 +1,273 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +func (h *Handler) SubjectsV1List(ctx context.Context, request api.SubjectsV1ListRequestObject) (api.SubjectsV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + query := toSubjectQuery(request.Params) + + subjects, err := h.academicService.GetSubjects(query) + if err != nil { + return nil, fmt.Errorf("failed to get subjects: %w", err) + } + + apiSubjects := make([]api.SubjectSummary, len(subjects)) + for i, subject := range subjects { + apiSubjects[i] = toApiSubjectSummary(subject) + } + + return api.SubjectsV1List200JSONResponse{ + Subjects: apiSubjects, + }, nil +} + +func (h *Handler) SubjectsV1Detail(ctx context.Context, request api.SubjectsV1DetailRequestObject) (api.SubjectsV1DetailResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + subject, err := h.academicService.GetSubject(request.Id) + if err != nil { + return nil, fmt.Errorf("failed to get subject: %w", err) + } + + return api.SubjectsV1Detail200JSONResponse{ + Subject: toApiSubjectDetail(*subject), + }, nil +} + +// toSubjectQuery は SubjectsV1List の API パラメータを domain.SubjectQuery に変換する +func toSubjectQuery(params api.SubjectsV1ListParams) domain.SubjectQuery { + var q string + if params.Q != nil { + q = *params.Q + } + + var grade []api.DottoFoundationV1Grade + if params.Grades != nil { + grade = *params.Grades + } + + var courses []api.DottoFoundationV1Course + if params.Courses != nil { + courses = *params.Courses + } + + var class []api.DottoFoundationV1Class + if params.Classes != nil { + class = *params.Classes + } + + var classification []api.DottoFoundationV1SubjectClassification + if params.Classifications != nil { + classification = *params.Classifications + } + + var semester []api.DottoFoundationV1CourseSemester + if params.Semesters != nil { + semester = *params.Semesters + } + + var requirementType []api.DottoFoundationV1SubjectRequirementType + if params.RequirementTypes != nil { + requirementType = *params.RequirementTypes + } + + var culturalSubjectCategory []api.DottoFoundationV1CulturalSubjectCategory + if params.CulturalSubjectCategories != nil { + culturalSubjectCategory = *params.CulturalSubjectCategories + } + + return domain.SubjectQuery{ + Q: q, + Grade: toGrades(grade), + Courses: toCourses(courses), + Class: toClasses(class), + Classification: toClassifications(classification), + Semester: toSemesters(semester), + RequirementType: toRequirementTypes(requirementType), + CulturalSubjectCategory: toCulturalSubjectCategories(culturalSubjectCategory), + } +} + +// toGrades はAPIの学年をDomainの学年に変換する +func toGrades(grades []api.DottoFoundationV1Grade) []domain.Grade { + result := make([]domain.Grade, len(grades)) + for i, g := range grades { + result[i] = domain.Grade(g) + } + return result +} + +// toCourses はAPIのコースをDomainのコースに変換する +func toCourses(courses []api.DottoFoundationV1Course) []domain.Course { + result := make([]domain.Course, len(courses)) + for i, c := range courses { + result[i] = domain.Course(c) + } + return result +} + +// toClasses はAPIのクラスをDomainのクラスに変換する +func toClasses(classes []api.DottoFoundationV1Class) []domain.Class { + result := make([]domain.Class, len(classes)) + for i, c := range classes { + result[i] = domain.Class(c) + } + return result +} + +// toClassifications はAPIの科目カテゴリをDomainの科目カテゴリに変換する +func toClassifications(classifications []api.DottoFoundationV1SubjectClassification) []domain.SubjectClassification { + result := make([]domain.SubjectClassification, len(classifications)) + for i, c := range classifications { + result[i] = domain.SubjectClassification(c) + } + return result +} + +// toSemesters はAPIの開講時期をDomainの開講時期に変換する +func toSemesters(semesters []api.DottoFoundationV1CourseSemester) []domain.CourseSemester { + result := make([]domain.CourseSemester, len(semesters)) + for i, s := range semesters { + result[i] = domain.CourseSemester(s) + } + return result +} + +// toRequirementTypes はAPIの必修・選択をDomainの必修・選択に変換する +func toRequirementTypes(types []api.DottoFoundationV1SubjectRequirementType) []domain.SubjectRequirementType { + result := make([]domain.SubjectRequirementType, len(types)) + for i, t := range types { + result[i] = domain.SubjectRequirementType(t) + } + return result +} + +// toCulturalSubjectCategories はAPIの教養科目カテゴリをDomainの教養科目カテゴリに変換する +func toCulturalSubjectCategories(categories []api.DottoFoundationV1CulturalSubjectCategory) []domain.CulturalSubjectCategory { + result := make([]domain.CulturalSubjectCategory, len(categories)) + for i, c := range categories { + result[i] = domain.CulturalSubjectCategory(c) + } + return result +} + +// toApiSubjectSummary はDomainの科目をAPIの科目サマリーに変換する +func toApiSubjectSummary(subject domain.Subject) api.SubjectSummary { + return api.SubjectSummary{ + Id: subject.ID, + Name: subject.Name, + Year: subject.Year, + Semester: api.DottoFoundationV1CourseSemester(subject.Semester), + Credit: subject.Credit, + Faculties: toApiFaculties(subject.Faculties), + } +} + +// toApiSubjectDetail はDomainの科目をAPIの科目に変換する +func toApiSubjectDetail(subject domain.Subject) api.SubjectDetail { + var syllabus api.AcademicServiceSyllabus + if subject.Syllabus != nil { + syllabus = toApiSyllabus(*subject.Syllabus) + } + + return api.SubjectDetail{ + Id: subject.ID, + Name: subject.Name, + Year: subject.Year, + Credit: subject.Credit, + Semester: api.DottoFoundationV1CourseSemester(subject.Semester), + Faculties: toApiFaculties(subject.Faculties), + Requirements: toApiRequirements(subject.Requirements), + EligibleAttributes: toApiTargetClasses(subject.EligibleAttributes), + Syllabus: syllabus, + } +} + +// toApiSyllabus はDomainのシラバスをAPIのシラバスに変換する +func toApiSyllabus(syllabus domain.Syllabus) api.AcademicServiceSyllabus { + return api.AcademicServiceSyllabus{ + Id: syllabus.ID, + Name: syllabus.Name, + EnName: syllabus.EnName, + Summary: syllabus.Summary, + LearningOutcomes: syllabus.LearningOutcomes, + ContentsAndSchedule: syllabus.ContentsAndSchedule, + PreLearning: syllabus.PreLearning, + PostLearning: syllabus.PostLearning, + Assignments: syllabus.Assignments, + EvaluationMethod: syllabus.EvaluationMethod, + Textbooks: syllabus.Textbooks, + ReferenceBooks: syllabus.ReferenceBooks, + Prerequisites: syllabus.Prerequisites, + Notes: syllabus.Notes, + Keywords: syllabus.Keywords, + Classifications: syllabus.Classifications, + Grades: syllabus.Grades, + Credit: syllabus.Credit, + FacultyNames: syllabus.FacultyNames, + TeachingForm: syllabus.TeachingForm, + TeachingAndExamForm: syllabus.TeachingAndExamForm, + TeachingLanguage: syllabus.TeachingLanguage, + MultiplePersonTeachingForm: syllabus.MultiplePersonTeachingForm, + PracticalHomeFacultyCategory: syllabus.PracticalHomeFacultyCategory, + DsopSubject: syllabus.DsopSubject, + TargetAreas: syllabus.TargetAreas, + TargetCourses: syllabus.TargetCourses, + } +} + +// toApiFaculties はDomainの教員をAPIの教員に変換する +func toApiFaculties(faculties []domain.SubjectFaculty) []api.SubjectFaculty { + result := make([]api.SubjectFaculty, len(faculties)) + for i, f := range faculties { + result[i] = api.SubjectFaculty{ + Faculty: api.AcademicServiceFaculty{ + Id: f.Faculty.ID, + Name: f.Faculty.Name, + Email: f.Faculty.Email, + }, + IsPrimary: f.IsPrimary, + } + } + return result +} + +// toApiRequirements はDomainの科目群・科目区分をAPIの科目群・科目区分に変換する +func toApiRequirements(requirements []domain.SubjectRequirement) []api.AcademicServiceSubjectRequirement { + result := make([]api.AcademicServiceSubjectRequirement, len(requirements)) + for i, r := range requirements { + result[i] = api.AcademicServiceSubjectRequirement{ + Course: api.DottoFoundationV1Course(r.Course), + RequirementType: api.DottoFoundationV1SubjectRequirementType(r.RequirementType), + } + } + return result +} + +// toApiTargetClasses はDomainの対象学年・クラスをAPIの対象学年・クラスに変換する +func toApiTargetClasses(targetClasses []domain.SubjectTargetClass) []api.AcademicServiceSubjectTargetClass { + result := make([]api.AcademicServiceSubjectTargetClass, len(targetClasses)) + for i, tc := range targetClasses { + var class *api.DottoFoundationV1Class + if tc.Class != nil { + c := api.DottoFoundationV1Class(*tc.Class) + class = &c + } + result[i] = api.AcademicServiceSubjectTargetClass{ + Grade: api.DottoFoundationV1Grade(tc.Grade), + Class: class, + } + } + return result +} diff --git a/internal/modules/app/handler/subject_test.go b/internal/modules/app/handler/subject_test.go new file mode 100644 index 0000000..49ab638 --- /dev/null +++ b/internal/modules/app/handler/subject_test.go @@ -0,0 +1,141 @@ +package handler + +import ( + "context" + "fmt" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSubjectsV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + validate func(t *testing.T, resp api.SubjectsV1ListResponseObject, err error) + }{ + { + name: "正常に科目一覧が取得できる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.SubjectsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.SubjectsV1List200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + assert.Len(t, result.Subjects, 1) + assert.Equal(t, "s1", result.Subjects[0].Id) + assert.Equal(t, "プログラミング基礎", result.Subjects[0].Name) + }, + }, + { + name: "科目のFacultyが正しくマッピングされる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + validate: func(t *testing.T, resp api.SubjectsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.SubjectsV1List200JSONResponse) + require.True(t, ok) + require.Len(t, result.Subjects, 1) + require.Len(t, result.Subjects[0].Faculties, 1) + assert.Equal(t, "f1", result.Subjects[0].Faculties[0].Faculty.Id) + assert.Equal(t, "田中太郎", result.Subjects[0].Faculties[0].Faculty.Name) + assert.True(t, result.Subjects[0].Faculties[0].IsPrimary) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + validate: func(t *testing.T, resp api.SubjectsV1ListResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getSubjects", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.SubjectsV1ListResponseObject, err error) { + require.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.SubjectsV1List(context.Background(), api.SubjectsV1ListRequestObject{}) + tt.validate(t, resp, err) + }) + } +} + +func TestSubjectsV1Detail(t *testing.T) { + tests := []struct { + name string + handler *Handler + request api.SubjectsV1DetailRequestObject + validate func(t *testing.T, resp api.SubjectsV1DetailResponseObject, err error) + }{ + { + name: "正常に科目詳細が取得できる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + request: api.SubjectsV1DetailRequestObject{Id: "s1"}, + validate: func(t *testing.T, resp api.SubjectsV1DetailResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.SubjectsV1Detail200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + assert.Equal(t, "s1", result.Subject.Id) + assert.Equal(t, "プログラミング基礎", result.Subject.Name) + assert.Equal(t, 2026, result.Subject.Year) + assert.Equal(t, 2, result.Subject.Credit) + assert.Equal(t, api.DottoFoundationV1CourseSemester("Q1"), result.Subject.Semester) + }, + }, + { + name: "科目詳細のRequirementsが正しくマッピングされる", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + request: api.SubjectsV1DetailRequestObject{Id: "s1"}, + validate: func(t *testing.T, resp api.SubjectsV1DetailResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.SubjectsV1Detail200JSONResponse) + require.True(t, ok) + require.Len(t, result.Subject.Requirements, 1) + assert.Equal(t, api.DottoFoundationV1Course("InformationSystem"), result.Subject.Requirements[0].Course) + assert.Equal(t, api.DottoFoundationV1SubjectRequirementType("Required"), result.Subject.Requirements[0].RequirementType) + }, + }, + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + request: api.SubjectsV1DetailRequestObject{Id: "s1"}, + validate: func(t *testing.T, resp api.SubjectsV1DetailResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errAcademicServiceNotConfigured) + }, + }, + { + name: "存在しない科目IDの場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository()))), + request: api.SubjectsV1DetailRequestObject{Id: "nonexistent"}, + validate: func(t *testing.T, resp api.SubjectsV1DetailResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get subject") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + handler: NewHandler(WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepositoryWithError("getSubject", fmt.Errorf("db error"))))), + request: api.SubjectsV1DetailRequestObject{Id: "s1"}, + validate: func(t *testing.T, resp api.SubjectsV1DetailResponseObject, err error) { + require.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.SubjectsV1Detail(context.Background(), tt.request) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/timetable_item.go b/internal/modules/app/handler/timetable_item.go new file mode 100644 index 0000000..39eb4fe --- /dev/null +++ b/internal/modules/app/handler/timetable_item.go @@ -0,0 +1,72 @@ +package handler + +import ( + "context" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// TimetableItemsV1List 時間割アイテム一覧を取得する +func (h *Handler) TimetableItemsV1List(ctx context.Context, request api.TimetableItemsV1ListRequestObject) (api.TimetableItemsV1ListResponseObject, error) { + if h.academicService == nil { + return nil, errAcademicServiceNotConfigured + } + + query := toTimetableItemQuery(request.Params) + + items, err := h.academicService.GetTimetableItems(query) + if err != nil { + return nil, fmt.Errorf("failed to get timetable items: %w", err) + } + + apiItems := make([]api.TimetableItem, len(items)) + for i, item := range items { + apiItems[i] = toApiTimetableItem(item) + } + + return api.TimetableItemsV1List200JSONResponse{ + TimetableItems: apiItems, + }, nil +} + +// toTimetableItemQuery は TimetableItemsV1List の API パラメータを domain.TimetableItemQuery に変換する +func toTimetableItemQuery(params api.TimetableItemsV1ListParams) domain.TimetableItemQuery { + query := domain.TimetableItemQuery{Year: params.Year} + query.Semesters = make([]domain.CourseSemester, len(params.Semesters)) + for i, semester := range params.Semesters { + query.Semesters[i] = domain.CourseSemester(semester) + } + return query +} + +// toApiTimetableItem はDomainの時間割アイテムをAPIの時間割アイテムに変換する +func toApiTimetableItem(item domain.TimetableItem) api.TimetableItem { + var slot *api.DottoFoundationV1TimetableSlot + if item.Slot != nil { + slot = &api.DottoFoundationV1TimetableSlot{ + DayOfWeek: api.DottoFoundationV1DayOfWeek(item.Slot.DayOfWeek), + Period: api.DottoFoundationV1Period(item.Slot.Period), + } + } + + return api.TimetableItem{ + Id: item.ID, + Slot: slot, + Rooms: toApiRooms(item.Rooms), + Subject: toApiSubjectSummary(item.Subject), + } +} + +func toApiRooms(rooms []domain.Room) []api.Room { + result := make([]api.Room, len(rooms)) + for i, room := range rooms { + result[i] = api.Room{ + Id: room.ID, + Name: room.Name, + Floor: api.DottoFoundationV1Floor(room.Floor), + } + } + return result +} diff --git a/internal/modules/app/handler/timetable_item_test.go b/internal/modules/app/handler/timetable_item_test.go new file mode 100644 index 0000000..9c6082a --- /dev/null +++ b/internal/modules/app/handler/timetable_item_test.go @@ -0,0 +1,58 @@ +package handler + +import ( + "context" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTimetableItemsV1List(t *testing.T) { + tests := []struct { + name string + handler *Handler + request api.TimetableItemsV1ListRequestObject + validate func(t *testing.T, resp api.TimetableItemsV1ListResponseObject, err error) + }{ + { + name: "academicServiceが未設定の場合エラーを返す", + handler: NewHandler(), + request: api.TimetableItemsV1ListRequestObject{}, + validate: func(t *testing.T, resp api.TimetableItemsV1ListResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "academicService is not configured") + }, + }, + { + name: "正常に時間割アイテム一覧を取得できる", + handler: NewHandler( + WithAcademicService(service.NewAcademicService(repository.NewMockAcademicRepository())), + ), + request: api.TimetableItemsV1ListRequestObject{ + Params: api.TimetableItemsV1ListParams{ + Semesters: []api.DottoFoundationV1CourseSemester{api.Q1}, + }, + }, + validate: func(t *testing.T, resp api.TimetableItemsV1ListResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.TimetableItemsV1List200JSONResponse) + require.True(t, ok) + assert.Len(t, result.TimetableItems, 1) + assert.Equal(t, "t1", result.TimetableItems[0].Id) + require.NotNil(t, result.TimetableItems[0].Slot) + assert.Len(t, result.TimetableItems[0].Rooms, 1) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.TimetableItemsV1List(context.Background(), tt.request) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/handler/user.go b/internal/modules/app/handler/user.go new file mode 100644 index 0000000..839894f --- /dev/null +++ b/internal/modules/app/handler/user.go @@ -0,0 +1,126 @@ +package handler + +import ( + "context" + "errors" + "fmt" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/middleware" +) + +// UsersV1Detail ユーザーを取得する +func (h *Handler) UsersV1Detail(ctx context.Context, request api.UsersV1DetailRequestObject) (api.UsersV1DetailResponseObject, error) { + if h.userService == nil { + return nil, errUserServiceNotConfigured + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.UsersV1Detail401Response{}, nil + } + + user, err := h.userService.GetUser(userID) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + return api.UsersV1Detail404Response{}, nil + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return api.UsersV1Detail200JSONResponse{ + User: toApiUserInfo(*user), + }, nil +} + +// UsersV1Upsert ユーザーを作成または更新する +func (h *Handler) UsersV1Upsert(ctx context.Context, request api.UsersV1UpsertRequestObject) (api.UsersV1UpsertResponseObject, error) { + if h.userService == nil { + return nil, errUserServiceNotConfigured + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.UsersV1Upsert401Response{}, nil + } + + email, _ := middleware.UserEmailFromContext(ctx) + + req := toDomainUserRequest(*request.Body) + req.Email = email + user, err := h.userService.UpsertUser(userID, req) + if err != nil { + return nil, fmt.Errorf("failed to upsert user: %w", err) + } + + return api.UsersV1Upsert200JSONResponse{ + User: toApiUserInfo(*user), + }, nil +} + +// FCMTokenV1Upsert FCMトークンを作成または更新する +func (h *Handler) FCMTokenV1Upsert(ctx context.Context, request api.FCMTokenV1UpsertRequestObject) (api.FCMTokenV1UpsertResponseObject, error) { + if h.userService == nil { + return nil, errUserServiceNotConfigured + } + if request.Body == nil { + return nil, fmt.Errorf("request body is required") + } + + userID, ok := middleware.UserIDFromContext(ctx) + if !ok { + return api.FCMTokenV1Upsert401Response{}, nil + } + + token, err := h.userService.UpsertFCMToken(userID, domain.FCMTokenRequest{Token: request.Body.Token}) + if err != nil { + return nil, fmt.Errorf("failed to upsert fcm token: %w", err) + } + + return api.FCMTokenV1Upsert200JSONResponse{ + FcmToken: toApiFCMToken(*token), + }, nil +} + +func toApiUserInfo(user domain.User) api.UserInfo { + info := api.UserInfo{} + if user.Grade != nil { + g := api.DottoFoundationV1Grade(*user.Grade) + info.Grade = &g + } + if user.Course != nil { + c := api.DottoFoundationV1Course(*user.Course) + info.Course = &c + } + if user.Class != nil { + cl := api.DottoFoundationV1Class(*user.Class) + info.Class = &cl + } + return info +} + +func toDomainUserRequest(body api.UsersV1UpsertJSONRequestBody) domain.UserRequest { + req := domain.UserRequest{} + if body.Grade != nil { + g := domain.Grade(*body.Grade) + req.Grade = &g + } + if body.Course != nil { + c := domain.Course(*body.Course) + req.Course = &c + } + if body.Class != nil { + cl := domain.Class(*body.Class) + req.Class = &cl + } + return req +} + +func toApiFCMToken(token domain.FCMToken) api.FCMToken { + return api.FCMToken{ + Token: token.Token, + CreatedAt: token.CreatedAt, + UpdatedAt: token.UpdatedAt, + } +} diff --git a/internal/modules/app/handler/user_test.go b/internal/modules/app/handler/user_test.go new file mode 100644 index 0000000..6363402 --- /dev/null +++ b/internal/modules/app/handler/user_test.go @@ -0,0 +1,232 @@ +package handler + +import ( + "context" + "fmt" + "testing" + "time" + + api "github.com/fun-dotto/server/gen/app" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/fun-dotto/server/internal/modules/app/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUsersV1Detail(t *testing.T) { + tests := []struct { + name string + ctx context.Context + handler *Handler + validate func(t *testing.T, resp api.UsersV1DetailResponseObject, err error) + }{ + { + name: "正常にユーザーが取得できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + validate: func(t *testing.T, resp api.UsersV1DetailResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.UsersV1Detail200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.NotNil(t, result.User.Grade) + assert.Equal(t, api.DottoFoundationV1Grade("B2"), *result.User.Grade) + require.NotNil(t, result.User.Course) + assert.Equal(t, api.DottoFoundationV1Course("InformationSystem"), *result.User.Course) + require.NotNil(t, result.User.Class) + assert.Equal(t, api.DottoFoundationV1Class("A"), *result.User.Class) + }, + }, + { + name: "ユーザーが見つからない場合404を返す", + ctx: ctxWithUserID("nonexistent"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + validate: func(t *testing.T, resp api.UsersV1DetailResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.UsersV1Detail404Response) + require.True(t, ok, "レスポンスが404レスポンスではありません") + }, + }, + { + name: "userServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + validate: func(t *testing.T, resp api.UsersV1DetailResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errUserServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + validate: func(t *testing.T, resp api.UsersV1DetailResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.UsersV1Detail401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepositoryWithError("getUser", fmt.Errorf("db error"))))), + validate: func(t *testing.T, resp api.UsersV1DetailResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get user") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.UsersV1Detail(tt.ctx, api.UsersV1DetailRequestObject{}) + tt.validate(t, resp, err) + }) + } +} + +func TestUsersV1Upsert(t *testing.T) { + grade := api.DottoFoundationV1Grade("B3") + course := api.DottoFoundationV1Course("InformationDesign") + class := api.DottoFoundationV1Class("B") + + tests := []struct { + name string + ctx context.Context + handler *Handler + body *api.UsersV1UpsertJSONRequestBody + validate func(t *testing.T, resp api.UsersV1UpsertResponseObject, err error) + }{ + { + name: "正常にユーザーを作成・更新できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + body: &api.UsersV1UpsertJSONRequestBody{Grade: &grade, Course: &course, Class: &class}, + validate: func(t *testing.T, resp api.UsersV1UpsertResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.UsersV1Upsert200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + require.NotNil(t, result.User.Grade) + assert.Equal(t, grade, *result.User.Grade) + require.NotNil(t, result.User.Course) + assert.Equal(t, course, *result.User.Course) + require.NotNil(t, result.User.Class) + assert.Equal(t, class, *result.User.Class) + }, + }, + { + name: "userServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + body: &api.UsersV1UpsertJSONRequestBody{Grade: &grade}, + validate: func(t *testing.T, resp api.UsersV1UpsertResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errUserServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + body: &api.UsersV1UpsertJSONRequestBody{Grade: &grade}, + validate: func(t *testing.T, resp api.UsersV1UpsertResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.UsersV1Upsert401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepositoryWithError("upsert", fmt.Errorf("db error"))))), + body: &api.UsersV1UpsertJSONRequestBody{Grade: &grade}, + validate: func(t *testing.T, resp api.UsersV1UpsertResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to upsert user") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.UsersV1Upsert(tt.ctx, api.UsersV1UpsertRequestObject{ + Body: tt.body, + }) + tt.validate(t, resp, err) + }) + } +} + +func TestFCMTokenV1Upsert(t *testing.T) { + tests := []struct { + name string + ctx context.Context + handler *Handler + body *api.FCMTokenV1UpsertJSONRequestBody + validate func(t *testing.T, resp api.FCMTokenV1UpsertResponseObject, err error) + }{ + { + name: "正常にFCMトークンを作成・更新できる", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + body: &api.FCMTokenV1UpsertJSONRequestBody{Token: "fcm-token-1"}, + validate: func(t *testing.T, resp api.FCMTokenV1UpsertResponseObject, err error) { + require.NoError(t, err) + result, ok := resp.(api.FCMTokenV1Upsert200JSONResponse) + require.True(t, ok, "レスポンスが200 JSONレスポンスではありません") + assert.Equal(t, "fcm-token-1", result.FcmToken.Token) + assert.WithinDuration(t, time.Now(), result.FcmToken.CreatedAt, time.Minute) + assert.WithinDuration(t, time.Now(), result.FcmToken.UpdatedAt, time.Minute) + }, + }, + { + name: "userServiceが未設定の場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(), + body: &api.FCMTokenV1UpsertJSONRequestBody{Token: "fcm-token-1"}, + validate: func(t *testing.T, resp api.FCMTokenV1UpsertResponseObject, err error) { + require.Error(t, err) + assert.ErrorIs(t, err, errUserServiceNotConfigured) + }, + }, + { + name: "コンテキストにユーザーIDがない場合401を返す", + ctx: context.Background(), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + body: &api.FCMTokenV1UpsertJSONRequestBody{Token: "fcm-token-1"}, + validate: func(t *testing.T, resp api.FCMTokenV1UpsertResponseObject, err error) { + require.NoError(t, err) + _, ok := resp.(api.FCMTokenV1Upsert401Response) + require.True(t, ok, "レスポンスが401レスポンスではありません") + }, + }, + { + name: "リクエストボディがnilの場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepository()))), + body: nil, + validate: func(t *testing.T, resp api.FCMTokenV1UpsertResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "request body is required") + }, + }, + { + name: "serviceがエラーを返す場合エラーを返す", + ctx: ctxWithUserID("user1"), + handler: NewHandler(WithUserService(service.NewUserService(repository.NewMockUserRepositoryWithError("upsertFCMToken", fmt.Errorf("db error"))))), + body: &api.FCMTokenV1UpsertJSONRequestBody{Token: "fcm-token-1"}, + validate: func(t *testing.T, resp api.FCMTokenV1UpsertResponseObject, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to upsert fcm token") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.handler.FCMTokenV1Upsert(tt.ctx, api.FCMTokenV1UpsertRequestObject{ + Body: tt.body, + }) + tt.validate(t, resp, err) + }) + } +} diff --git a/internal/modules/app/middleware/appcheck.go b/internal/modules/app/middleware/appcheck.go new file mode 100644 index 0000000..065e4e0 --- /dev/null +++ b/internal/modules/app/middleware/appcheck.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + "strings" + + "firebase.google.com/go/v4/appcheck" + api "github.com/fun-dotto/server/gen/app" + "github.com/gin-gonic/gin" +) + +// AppCheckMiddleware returns a Gin middleware that verifies Firebase App Check tokens. +func AppCheckMiddleware(appCheckClient *appcheck.Client) gin.HandlerFunc { + return func(c *gin.Context) { + if !requiresFirebaseAppCheck(c) { + c.Next() + return + } + + token := c.GetHeader("X-Firebase-AppCheck") + if token == "" { + message := "X-Firebase-AppCheck header is required" + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": message}) + return + } + + _, err := appCheckClient.VerifyToken(strings.Replace(token, "Bearer ", "", 1)) + if err != nil { + message := "X-Firebase-AppCheck header value is invalid" + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": message}) + return + } + + c.Next() + } +} + +func requiresFirebaseAppCheck(c *gin.Context) bool { + _, ok := c.Get(api.FirebaseAppCheckAuthScopes) + return ok +} diff --git a/internal/modules/app/middleware/appcheck_test.go b/internal/modules/app/middleware/appcheck_test.go new file mode 100644 index 0000000..3499ae1 --- /dev/null +++ b/internal/modules/app/middleware/appcheck_test.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppCheckMiddleware(t *testing.T) { + t.Setenv("GIN_MODE", "test") + gin.SetMode(gin.TestMode) + + t.Run("AppCheck が不要な場合はヘッダーがなくても通す", func(t *testing.T) { + router := gin.New() + router.Use(AppCheckMiddleware(nil)) + router.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("AppCheck が必要な場合はヘッダーが必須", func(t *testing.T) { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(api.FirebaseAppCheckAuthScopes, []string{}) + c.Next() + }) + router.Use(AppCheckMiddleware(nil)) + router.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.JSONEq(t, `{"error":"X-Firebase-AppCheck header is required"}`, rec.Body.String()) + }) +} diff --git a/internal/modules/app/middleware/auth.go b/internal/modules/app/middleware/auth.go new file mode 100644 index 0000000..1d297ef --- /dev/null +++ b/internal/modules/app/middleware/auth.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "firebase.google.com/go/v4/auth" + api "github.com/fun-dotto/server/gen/app" + "github.com/gin-gonic/gin" +) + +const ( + userIDKey = "userID" + userEmailKey = "userEmail" +) + +// AuthMiddleware returns a Gin middleware that verifies Firebase ID tokens +// and stores the user ID in the context. +func AuthMiddleware(authClient *auth.Client) gin.HandlerFunc { + return func(c *gin.Context) { + if !requiresBearerAuth(c) { + c.Next() + return + } + + authorization := c.GetHeader("Authorization") + if authorization == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + return + } + + idToken := strings.TrimPrefix(authorization, "Bearer ") + + token, err := authClient.VerifyIDToken(c.Request.Context(), idToken) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid ID token"}) + return + } + + c.Set(userIDKey, token.UID) + + if email, ok := token.Claims["email"].(string); ok { + c.Set(userEmailKey, email) + } + + c.Next() + } +} + +func requiresBearerAuth(c *gin.Context) bool { + _, ok := c.Get(api.BearerAuthScopes) + return ok +} + +// UserIDFromContext extracts the user ID from context. +func UserIDFromContext(ctx context.Context) (string, bool) { + userID, ok := ctx.Value(userIDKey).(string) + return userID, ok +} + +// UserEmailFromContext extracts the user email from context. +func UserEmailFromContext(ctx context.Context) (string, bool) { + email, ok := ctx.Value(userEmailKey).(string) + return email, ok +} diff --git a/internal/modules/app/middleware/auth_test.go b/internal/modules/app/middleware/auth_test.go new file mode 100644 index 0000000..fcc380a --- /dev/null +++ b/internal/modules/app/middleware/auth_test.go @@ -0,0 +1,89 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + api "github.com/fun-dotto/server/gen/app" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthMiddleware(t *testing.T) { + t.Setenv("GIN_MODE", "test") + gin.SetMode(gin.TestMode) + + t.Run("BearerAuth が不要な場合は Authorization がなくても通す", func(t *testing.T) { + router := gin.New() + router.Use(AuthMiddleware(nil)) + router.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("BearerAuth が必要な場合は Authorization が必須", func(t *testing.T) { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(api.BearerAuthScopes, []string{}) + c.Next() + }) + router.Use(AuthMiddleware(nil)) + router.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.JSONEq(t, `{"error":"Authorization header is required"}`, rec.Body.String()) + }) +} + +func TestUserIDFromContext(t *testing.T) { + t.Run("context に userID が存在する", func(t *testing.T) { + ctx := contextWithUserID("user-123") + got, ok := UserIDFromContext(ctx) + require.True(t, ok) + require.Equal(t, "user-123", got) + }) + + t.Run("context に userID が存在しない", func(t *testing.T) { + got, ok := UserIDFromContext(t.Context()) + require.False(t, ok) + require.Empty(t, got) + }) +} + +func TestUserEmailFromContext(t *testing.T) { + t.Run("context に userEmail が存在する", func(t *testing.T) { + ctx := contextWithUserEmail("user@example.com") + got, ok := UserEmailFromContext(ctx) + require.True(t, ok) + require.Equal(t, "user@example.com", got) + }) + + t.Run("context に userEmail が存在しない", func(t *testing.T) { + got, ok := UserEmailFromContext(t.Context()) + require.False(t, ok) + require.Empty(t, got) + }) +} + +func contextWithUserID(userID string) context.Context { + return context.WithValue(context.Background(), userIDKey, userID) +} + +func contextWithUserEmail(email string) context.Context { + return context.WithValue(context.Background(), userEmailKey, email) +} diff --git a/internal/modules/app/repository/academic.go b/internal/modules/app/repository/academic.go new file mode 100644 index 0000000..e583665 --- /dev/null +++ b/internal/modules/app/repository/academic.go @@ -0,0 +1,300 @@ +package repository + +import ( + "context" + "fmt" + "time" + + academic_api "github.com/fun-dotto/server/gen/academic" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/external" +) + +type AcademicRepository struct { + client *academic_api.ClientWithResponses +} + +func NewAcademicRepository(client *academic_api.ClientWithResponses) *AcademicRepository { + return &AcademicRepository{client: client} +} + +// GetFaculties は全教員一覧を取得する +func (r *AcademicRepository) GetFaculties() ([]domain.Faculty, error) { + response, err := r.client.FacultiesV1ListWithResponse(context.Background(), nil) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get faculties: status %d", response.StatusCode()) + } + + return external.ToDomainFaculties(response.JSON200.Faculties), nil +} + +// GetFaculty は教員詳細を取得する +func (r *AcademicRepository) GetFaculty(id string) (*domain.Faculty, error) { + response, err := r.client.FacultiesV1DetailWithResponse(context.Background(), id) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get faculty: status %d", response.StatusCode()) + } + + faculty := external.ToDomainFaculty(response.JSON200.Faculty) + return &faculty, nil +} + +// GetSubjects は外部APIから科目一覧を取得する +func (r *AcademicRepository) GetSubjects(query domain.SubjectQuery) ([]domain.Subject, error) { + params := external.ToExternalSubjectQuery(query) + + response, err := r.client.SubjectsV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, err + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get subjects: status %d", response.StatusCode()) + } + + subjects := response.JSON200.Subjects + result := make([]domain.Subject, len(subjects)) + for i, s := range subjects { + result[i] = external.ToDomainSubjectSummary(s) + } + + return result, nil +} + +// GetCourseRegistrations は外部APIから履修登録一覧を取得する +func (r *AcademicRepository) GetCourseRegistrations(userID string, semesters []domain.CourseSemester, year *int) ([]domain.CourseRegistration, error) { + params := &academic_api.CourseRegistrationsV1ListParams{ + UserId: userID, + Semesters: externalToCourseSemesters(semesters), + Year: year, + } + + response, err := r.client.CourseRegistrationsV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get course registrations: status %d", response.StatusCode()) + } + + registrations := response.JSON200.CourseRegistrations + result := make([]domain.CourseRegistration, len(registrations)) + for i, r := range registrations { + result[i] = external.ToDomainCourseRegistration(r) + } + + return result, nil +} + +// CreateCourseRegistration は外部APIに履修登録を作成する +func (r *AcademicRepository) CreateCourseRegistration(userID string, subjectID string) (*domain.CourseRegistration, error) { + body := academic_api.CourseRegistrationsV1CreateJSONRequestBody{ + SubjectId: subjectID, + UserId: userID, + } + + response, err := r.client.CourseRegistrationsV1CreateWithResponse(context.Background(), body) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON201 == nil { + return nil, fmt.Errorf("failed to create course registration: status %d", response.StatusCode()) + } + + cr := external.ToDomainCourseRegistration(response.JSON201.CourseRegistration) + return &cr, nil +} + +// DeleteCourseRegistration は外部APIから履修登録を削除する +func (r *AcademicRepository) DeleteCourseRegistration(id string) error { + response, err := r.client.CourseRegistrationsV1DeleteWithResponse(context.Background(), id) + if err != nil { + return fmt.Errorf("failed to call academic API: %w", err) + } + + if response.StatusCode() != 204 { + return fmt.Errorf("failed to delete course registration: status %d", response.StatusCode()) + } + + return nil +} + +// GetTimetableItems は外部APIから時間割アイテム一覧を取得する +func (r *AcademicRepository) GetTimetableItems(query domain.TimetableItemQuery) ([]domain.TimetableItem, error) { + params := external.ToExternalTimetableItemQuery(query) + + response, err := r.client.TimetableItemsV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get timetable items: status %d", response.StatusCode()) + } + + items := response.JSON200.TimetableItems + result := make([]domain.TimetableItem, len(items)) + for i, item := range items { + result[i] = external.ToDomainTimetableItem(item) + } + + return result, nil +} + +// GetPersonalCalendarItems は外部APIから個人カレンダーアイテム一覧を取得する +func (r *AcademicRepository) GetPersonalCalendarItems(userID string, dates []time.Time) ([]domain.PersonalCalendarItem, error) { + params := external.ToExternalPersonalCalendarItemParams(userID, dates) + + response, err := r.client.PersonalCalendarItemsV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get personal calendar items: status %d", response.StatusCode()) + } + + items := response.JSON200.PersonalCalendarItems + result := make([]domain.PersonalCalendarItem, len(items)) + for i, item := range items { + result[i] = external.ToDomainPersonalCalendarItem(item) + } + + return result, nil +} + +// GetSubject は外部APIから科目詳細を取得する +func (r *AcademicRepository) GetSubject(id string) (*domain.Subject, error) { + ctx := context.Background() + + // 科目詳細を取得 + subjectResponse, err := r.client.SubjectsV1DetailWithResponse(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get subject: %w", err) + } + if subjectResponse.JSON200 == nil { + return nil, fmt.Errorf("failed to get subject: status %d", subjectResponse.StatusCode()) + } + + // シラバスを取得 + syllabusResponse, err := r.client.SyllabusV1DetailWithResponse(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get syllabus: %w", err) + } + if syllabusResponse.JSON200 == nil { + return nil, fmt.Errorf("failed to get syllabus: status %d", syllabusResponse.StatusCode()) + } + + s := external.ToDomainSubject(subjectResponse.JSON200.Subject) + + syllabus := external.ToDomainSyllabus(syllabusResponse.JSON200.Syllabus) + s.Syllabus = &syllabus + + return &s, nil +} + +// GetCancelledClasses は外部APIから休講一覧を取得する +func (r *AcademicRepository) GetCancelledClasses(query domain.CancelledClassQuery) ([]domain.CancelledClass, error) { + params := external.ToExternalCancelledClassQuery(query) + + response, err := r.client.CancelledClassesV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get cancelled classes: status %d", response.StatusCode()) + } + + items := response.JSON200.CancelledClasses + result := make([]domain.CancelledClass, len(items)) + for i, item := range items { + result[i] = external.ToDomainCancelledClass(item) + } + + return result, nil +} + +// GetMakeupClasses は外部APIから補講一覧を取得する +func (r *AcademicRepository) GetMakeupClasses(query domain.MakeupClassQuery) ([]domain.MakeupClass, error) { + params := external.ToExternalMakeupClassQuery(query) + + response, err := r.client.MakeupClassesV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get makeup classes: status %d", response.StatusCode()) + } + + items := response.JSON200.MakeupClasses + result := make([]domain.MakeupClass, len(items)) + for i, item := range items { + result[i] = external.ToDomainMakeupClass(item) + } + + return result, nil +} + +// GetRoomChanges は外部APIから教室変更一覧を取得する +func (r *AcademicRepository) GetRoomChanges(query domain.RoomChangeQuery) ([]domain.RoomChange, error) { + params := external.ToExternalRoomChangeQuery(query) + + response, err := r.client.RoomChangesV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get room changes: status %d", response.StatusCode()) + } + + items := response.JSON200.RoomChanges + result := make([]domain.RoomChange, len(items)) + for i, item := range items { + result[i] = external.ToDomainRoomChange(item) + } + + return result, nil +} + +// GetReservations は外部APIから予約一覧を取得する +func (r *AcademicRepository) GetReservations(query domain.ReservationQuery) ([]domain.Reservation, error) { + params := external.ToExternalReservationQuery(query) + + response, err := r.client.ReservationsV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call academic API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get reservations: status %d", response.StatusCode()) + } + + items := response.JSON200.Reservations + result := make([]domain.Reservation, len(items)) + for i, item := range items { + result[i] = external.ToDomainReservation(item) + } + + return result, nil +} + +func externalToCourseSemesters(semesters []domain.CourseSemester) []academic_api.DottoFoundationV1CourseSemester { + result := make([]academic_api.DottoFoundationV1CourseSemester, len(semesters)) + for i, semester := range semesters { + result[i] = academic_api.DottoFoundationV1CourseSemester(semester) + } + return result +} diff --git a/internal/modules/app/repository/academic_mock.go b/internal/modules/app/repository/academic_mock.go new file mode 100644 index 0000000..0458826 --- /dev/null +++ b/internal/modules/app/repository/academic_mock.go @@ -0,0 +1,254 @@ +package repository + +import ( + "fmt" + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// MockAcademicRepository はテスト用のモック +type MockAcademicRepository struct { + subjects []domain.Subject + courseRegistrations []domain.CourseRegistration + personalCalendarItems []domain.PersonalCalendarItem + faculties []domain.Faculty + createError error + deleteError error + getSubjectsError error + getSubjectError error + getRegistrationsErr error + getPersonalCalendarItemsErr error + getCancelledClassesErr error + getMakeupClassesErr error + getRoomChangesErr error + getReservationsErr error +} + +func NewMockAcademicRepository() *MockAcademicRepository { + faculty := domain.Faculty{ + ID: "f1", + Name: "田中太郎", + Email: "tanaka@example.com", + } + + subject := domain.Subject{ + ID: "s1", + Name: "プログラミング基礎", + Year: 2026, + Credit: 2, + Semester: domain.CourseSemesterQ1, + Faculties: []domain.SubjectFaculty{ + {Faculty: faculty, IsPrimary: true}, + }, + Requirements: []domain.SubjectRequirement{ + {Course: domain.CourseInformationSystem, RequirementType: domain.SubjectRequirementTypeRequired}, + }, + EligibleAttributes: []domain.SubjectTargetClass{ + {Grade: domain.GradeB1, Class: nil}, + }, + } + + slot := domain.TimetableSlot{ + DayOfWeek: domain.DayOfWeekMonday, + Period: domain.PeriodPeriod1, + } + + room := domain.Room{ + ID: "r1", + Name: "101講義室", + Floor: domain.Floor1, + } + + return &MockAcademicRepository{ + subjects: []domain.Subject{subject}, + courseRegistrations: []domain.CourseRegistration{ + {ID: "cr1", Subject: subject}, + }, + personalCalendarItems: []domain.PersonalCalendarItem{ + { + Date: time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC), + Period: slot.Period, + Rooms: []domain.Room{room}, + Status: domain.PersonalCalendarItemStatusNormal, + Subject: subject, + }, + }, + faculties: []domain.Faculty{faculty}, + } +} + +func NewMockAcademicRepositoryWithError(field string, err error) *MockAcademicRepository { + m := NewMockAcademicRepository() + switch field { + case "create": + m.createError = err + case "delete": + m.deleteError = err + case "getSubjects": + m.getSubjectsError = err + case "getSubject": + m.getSubjectError = err + case "getRegistrations": + m.getRegistrationsErr = err + case "getPersonalCalendarItems": + m.getPersonalCalendarItemsErr = err + case "getCancelledClasses": + m.getCancelledClassesErr = err + case "getMakeupClasses": + m.getMakeupClassesErr = err + case "getRoomChanges": + m.getRoomChangesErr = err + case "getReservations": + m.getReservationsErr = err + } + return m +} + +func (m *MockAcademicRepository) GetFaculties() ([]domain.Faculty, error) { + return m.faculties, nil +} + +func (m *MockAcademicRepository) GetFaculty(id string) (*domain.Faculty, error) { + for _, f := range m.faculties { + if f.ID == id { + return &f, nil + } + } + return nil, fmt.Errorf("faculty not found: %s", id) +} + +func (m *MockAcademicRepository) GetSubjects(_ domain.SubjectQuery) ([]domain.Subject, error) { + if m.getSubjectsError != nil { + return nil, m.getSubjectsError + } + return m.subjects, nil +} + +func (m *MockAcademicRepository) GetSubject(id string) (*domain.Subject, error) { + if m.getSubjectError != nil { + return nil, m.getSubjectError + } + for _, s := range m.subjects { + if s.ID == id { + return &s, nil + } + } + return nil, fmt.Errorf("subject not found: %s", id) +} + +func (m *MockAcademicRepository) GetCourseRegistrations(_ string, _ []domain.CourseSemester, _ *int) ([]domain.CourseRegistration, error) { + if m.getRegistrationsErr != nil { + return nil, m.getRegistrationsErr + } + return m.courseRegistrations, nil +} + +func (m *MockAcademicRepository) CreateCourseRegistration(_ string, _ string) (*domain.CourseRegistration, error) { + if m.createError != nil { + return nil, m.createError + } + reg := domain.CourseRegistration{ + ID: "cr-new", + Subject: m.subjects[0], + } + return ®, nil +} + +func (m *MockAcademicRepository) DeleteCourseRegistration(_ string) error { + if m.deleteError != nil { + return m.deleteError + } + return nil +} + +func (m *MockAcademicRepository) GetTimetableItems(_ domain.TimetableItemQuery) ([]domain.TimetableItem, error) { + return []domain.TimetableItem{ + { + ID: "t1", + Slot: &domain.TimetableSlot{DayOfWeek: domain.DayOfWeekMonday, Period: m.personalCalendarItems[0].Period}, + Rooms: m.personalCalendarItems[0].Rooms, + Subject: m.personalCalendarItems[0].Subject, + }, + }, nil +} + +func (m *MockAcademicRepository) GetPersonalCalendarItems(_ string, _ []time.Time) ([]domain.PersonalCalendarItem, error) { + if m.getPersonalCalendarItemsErr != nil { + return nil, m.getPersonalCalendarItemsErr + } + return m.personalCalendarItems, nil +} + +func (m *MockAcademicRepository) GetCancelledClasses(_ domain.CancelledClassQuery) ([]domain.CancelledClass, error) { + if m.getCancelledClassesErr != nil { + return nil, m.getCancelledClassesErr + } + return []domain.CancelledClass{ + { + ID: "cancelled-1", + Comment: "教員出張のため", + Date: time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC), + Period: domain.PeriodPeriod1, + Subject: m.subjects[0], + }, + }, nil +} + +func (m *MockAcademicRepository) GetMakeupClasses(_ domain.MakeupClassQuery) ([]domain.MakeupClass, error) { + if m.getMakeupClassesErr != nil { + return nil, m.getMakeupClassesErr + } + return []domain.MakeupClass{ + { + ID: "makeup-1", + Comment: "休講分の補講", + Date: time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC), + Period: domain.PeriodPeriod2, + Subject: m.subjects[0], + }, + }, nil +} + +func (m *MockAcademicRepository) GetReservations(_ domain.ReservationQuery) ([]domain.Reservation, error) { + if m.getReservationsErr != nil { + return nil, m.getReservationsErr + } + return []domain.Reservation{ + { + ID: "reservation-1", + Room: domain.Room{ + ID: "r1", + Name: "101講義室", + Floor: domain.Floor1, + }, + StartAt: time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC), + EndAt: time.Date(2026, 4, 20, 11, 30, 0, 0, time.UTC), + Title: "ゼミ", + }, + }, nil +} + +func (m *MockAcademicRepository) GetRoomChanges(_ domain.RoomChangeQuery) ([]domain.RoomChange, error) { + if m.getRoomChangesErr != nil { + return nil, m.getRoomChangesErr + } + return []domain.RoomChange{ + { + ID: "room-change-1", + Date: time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC), + Period: domain.PeriodPeriod3, + Subject: m.subjects[0], + OriginalRoom: domain.Room{ + ID: "r1", + Name: "101講義室", + Floor: domain.Floor1, + }, + NewRoom: domain.Room{ + ID: "r2", + Name: "201講義室", + Floor: domain.Floor2, + }, + }, + }, nil +} diff --git a/internal/modules/app/repository/announcement.go b/internal/modules/app/repository/announcement.go new file mode 100644 index 0000000..744a4e9 --- /dev/null +++ b/internal/modules/app/repository/announcement.go @@ -0,0 +1,107 @@ +package repository + +import ( + "context" + "fmt" + + announcement_api "github.com/fun-dotto/server/gen/announcement" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/external" +) + +// AnnouncementRepository は外部APIからお知らせを取得する +type AnnouncementRepository struct { + client *announcement_api.ClientWithResponses +} + +func NewAnnouncementRepository(client *announcement_api.ClientWithResponses) *AnnouncementRepository { + return &AnnouncementRepository{client: client} +} + +func (r *AnnouncementRepository) GetAnnouncements(query domain.AnnouncementQuery) ([]domain.Announcement, error) { + params := announcement_api.AnnouncementsV1ListParams{ + SortByDate: func() *announcement_api.FoundationV1SortDirection { + if query.SortByDate == nil { + return nil + } + sortDirection := announcement_api.FoundationV1SortDirection(*query.SortByDate) + return &sortDirection + }(), + FilterIsActive: query.FilterIsActive, + } + // 外部APIからデータ取得 + response, err := r.client.AnnouncementsV1ListWithResponse(context.Background(), ¶ms) + if err != nil { + return nil, err + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get announcements: status %d", response.StatusCode()) + } + + // 外部API形式 → ドメイン形式に変換 + announcements := response.JSON200.Announcements + result := make([]domain.Announcement, len(announcements)) + for i, a := range announcements { + result[i] = external.ToDomainAnnouncement(a) + } + + return result, nil +} + +func (r *AnnouncementRepository) GetAnnouncement(id string) (*domain.Announcement, error) { + response, err := r.client.AnnouncementsV1DetailWithResponse(context.Background(), id) + if err != nil { + return nil, err + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get announcement: status %d", response.StatusCode()) + } + + a := external.ToDomainAnnouncement(response.JSON200.Announcement) + return &a, nil +} + +func (r *AnnouncementRepository) CreateAnnouncement(req domain.AnnouncementRequest) (*domain.Announcement, error) { + body := external.ToExternalAnnouncementRequest(req) + response, err := r.client.AnnouncementsV1CreateWithResponse(context.Background(), body) + if err != nil { + return nil, err + } + + if response.JSON201 == nil { + return nil, fmt.Errorf("failed to create announcement: status %d", response.StatusCode()) + } + + a := external.ToDomainAnnouncement(response.JSON201.Announcement) + return &a, nil +} + +func (r *AnnouncementRepository) UpdateAnnouncement(id string, req domain.AnnouncementRequest) (*domain.Announcement, error) { + body := external.ToExternalAnnouncementRequest(req) + response, err := r.client.AnnouncementsV1UpdateWithResponse(context.Background(), id, body) + if err != nil { + return nil, err + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to update announcement: status %d", response.StatusCode()) + } + + a := external.ToDomainAnnouncement(response.JSON200.Announcement) + return &a, nil +} + +func (r *AnnouncementRepository) DeleteAnnouncement(id string) error { + response, err := r.client.AnnouncementsV1DeleteWithResponse(context.Background(), id) + if err != nil { + return err + } + + if response.StatusCode() != 204 { + return fmt.Errorf("failed to delete announcement: status %d", response.StatusCode()) + } + + return nil +} diff --git a/internal/modules/app/repository/announcement_mock.go b/internal/modules/app/repository/announcement_mock.go new file mode 100644 index 0000000..ccaf263 --- /dev/null +++ b/internal/modules/app/repository/announcement_mock.go @@ -0,0 +1,112 @@ +package repository + +import ( + "fmt" + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// MockAnnouncementRepository はテスト用のモック +type MockAnnouncementRepository struct { + announcements []domain.Announcement + getAnnouncementsErr error + getAnnouncementErr error + createErr error + updateErr error + deleteErr error +} + +func NewMockAnnouncementRepository() *MockAnnouncementRepository { + return &MockAnnouncementRepository{ + announcements: []domain.Announcement{ + { + ID: "1", + Title: "Announcement 1", + AvailableFrom: time.Now(), + AvailableUntil: nil, + URL: "https://example.com", + }, + }, + } +} + +func NewMockAnnouncementRepositoryWithError(field string, err error) *MockAnnouncementRepository { + m := NewMockAnnouncementRepository() + switch field { + case "getAnnouncements": + m.getAnnouncementsErr = err + case "getAnnouncement": + m.getAnnouncementErr = err + case "create": + m.createErr = err + case "update": + m.updateErr = err + case "delete": + m.deleteErr = err + } + return m +} + +func (m *MockAnnouncementRepository) GetAnnouncements(_ domain.AnnouncementQuery) ([]domain.Announcement, error) { + if m.getAnnouncementsErr != nil { + return nil, m.getAnnouncementsErr + } + return m.announcements, nil +} + +func (m *MockAnnouncementRepository) GetAnnouncement(id string) (*domain.Announcement, error) { + if m.getAnnouncementErr != nil { + return nil, m.getAnnouncementErr + } + for _, a := range m.announcements { + if a.ID == id { + return &a, nil + } + } + return nil, fmt.Errorf("announcement not found: %s", id) +} + +func (m *MockAnnouncementRepository) CreateAnnouncement(req domain.AnnouncementRequest) (*domain.Announcement, error) { + if m.createErr != nil { + return nil, m.createErr + } + a := domain.Announcement{ + ID: fmt.Sprintf("%d", len(m.announcements)+1), + Title: req.Title, + AvailableFrom: req.AvailableFrom, + AvailableUntil: req.AvailableUntil, + URL: req.URL, + } + m.announcements = append(m.announcements, a) + return &a, nil +} + +func (m *MockAnnouncementRepository) UpdateAnnouncement(id string, req domain.AnnouncementRequest) (*domain.Announcement, error) { + if m.updateErr != nil { + return nil, m.updateErr + } + for i, a := range m.announcements { + if a.ID == id { + m.announcements[i].Title = req.Title + m.announcements[i].AvailableFrom = req.AvailableFrom + m.announcements[i].AvailableUntil = req.AvailableUntil + m.announcements[i].URL = req.URL + return &m.announcements[i], nil + } + } + return nil, fmt.Errorf("announcement not found: %s", id) +} + +func (m *MockAnnouncementRepository) DeleteAnnouncement(id string) error { + if m.deleteErr != nil { + return m.deleteErr + } + for i, a := range m.announcements { + if a.ID == id { + m.announcements = append(m.announcements[:i], m.announcements[i+1:]...) + return nil + } + } + return fmt.Errorf("announcement not found: %s", id) +} diff --git a/internal/modules/app/repository/funch.go b/internal/modules/app/repository/funch.go new file mode 100644 index 0000000..c018cc3 --- /dev/null +++ b/internal/modules/app/repository/funch.go @@ -0,0 +1,40 @@ +package repository + +import ( + "context" + "fmt" + + funch_api "github.com/fun-dotto/server/gen/funch" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/external" +) + +type FunchRepository struct { + client *funch_api.ClientWithResponses +} + +func NewFunchRepository(client *funch_api.ClientWithResponses) *FunchRepository { + return &FunchRepository{client: client} +} + +// GetMenuItems は外部APIからメニュー一覧を取得する +func (r *FunchRepository) GetMenuItems(query domain.MenuItemQuery) ([]domain.MenuItem, error) { + params := external.ToExternalMenuItemQuery(query) + + response, err := r.client.MenuItemsV1ListWithResponse(context.Background(), params) + if err != nil { + return nil, fmt.Errorf("failed to call funch API: %w", err) + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get menu items: status %d", response.StatusCode()) + } + + items := response.JSON200.MenuItems + result := make([]domain.MenuItem, len(items)) + for i, item := range items { + result[i] = external.ToDomainMenuItem(item) + } + + return result, nil +} diff --git a/internal/modules/app/repository/funch_mock.go b/internal/modules/app/repository/funch_mock.go new file mode 100644 index 0000000..7b815cc --- /dev/null +++ b/internal/modules/app/repository/funch_mock.go @@ -0,0 +1,46 @@ +package repository + +import ( + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// MockFunchRepository はテスト用のモック +type MockFunchRepository struct { + menuItems []domain.MenuItem + getMenuItemsErr error +} + +func NewMockFunchRepository() *MockFunchRepository { + return &MockFunchRepository{ + menuItems: []domain.MenuItem{ + { + ID: "menu-1", + Date: time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC), + Name: "からあげ定食", + ImageURL: "https://example.com/karaage.png", + Category: domain.MenuCategorySetAndSingle, + Prices: []domain.MenuPrice{ + {Size: domain.MenuSizeMedium, Price: 500}, + }, + }, + }, + } +} + +func NewMockFunchRepositoryWithError(field string, err error) *MockFunchRepository { + m := NewMockFunchRepository() + switch field { + case "getMenuItems": + m.getMenuItemsErr = err + } + return m +} + +func (m *MockFunchRepository) GetMenuItems(_ domain.MenuItemQuery) ([]domain.MenuItem, error) { + if m.getMenuItemsErr != nil { + return nil, m.getMenuItemsErr + } + return m.menuItems, nil +} diff --git a/internal/modules/app/repository/user.go b/internal/modules/app/repository/user.go new file mode 100644 index 0000000..218d095 --- /dev/null +++ b/internal/modules/app/repository/user.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + "fmt" + "net/http" + + user_api "github.com/fun-dotto/server/gen/user" + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/external" +) + +// UserRepository は外部APIからユーザーを取得する +type UserRepository struct { + client *user_api.ClientWithResponses +} + +func NewUserRepository(client *user_api.ClientWithResponses) *UserRepository { + return &UserRepository{client: client} +} + +func (r *UserRepository) GetUser(id string) (*domain.User, error) { + response, err := r.client.UsersV1DetailWithResponse(context.Background(), id) + if err != nil { + return nil, err + } + + if response.StatusCode() == http.StatusNotFound { + return nil, domain.ErrUserNotFound + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to get user: status %d", response.StatusCode()) + } + + u := external.ToDomainUser(response.JSON200.User) + return &u, nil +} + +func (r *UserRepository) UpsertUser(id string, req domain.UserRequest) (*domain.User, error) { + body := external.ToExternalUserRequest(req) + response, err := r.client.UsersV1UpsertWithResponse(context.Background(), id, body) + if err != nil { + return nil, err + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to upsert user: status %d", response.StatusCode()) + } + + u := external.ToDomainUser(response.JSON200.User) + return &u, nil +} + +func (r *UserRepository) UpsertFCMToken(userID string, req domain.FCMTokenRequest) (*domain.FCMToken, error) { + body := external.ToExternalFCMTokenRequest(userID, req) + response, err := r.client.FCMTokenV1UpsertWithResponse(context.Background(), body) + if err != nil { + return nil, err + } + + if response.JSON200 == nil { + return nil, fmt.Errorf("failed to upsert fcm token: status %d", response.StatusCode()) + } + + token := external.ToDomainFCMToken(response.JSON200.FcmToken) + return &token, nil +} diff --git a/internal/modules/app/repository/user_mock.go b/internal/modules/app/repository/user_mock.go new file mode 100644 index 0000000..38e2675 --- /dev/null +++ b/internal/modules/app/repository/user_mock.go @@ -0,0 +1,101 @@ +package repository + +import ( + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +// MockUserRepository はテスト用のモック +type MockUserRepository struct { + users map[string]domain.User + upsertErr error + getUserErr error + upsertFCMTokenErr error + fcmTokens map[string]domain.FCMToken +} + +func NewMockUserRepository() *MockUserRepository { + grade := domain.GradeB2 + course := domain.CourseInformationSystem + class := domain.ClassA + + return &MockUserRepository{ + users: map[string]domain.User{ + "user1": { + ID: "user1", + Email: "user1@example.com", + Grade: &grade, + Course: &course, + Class: &class, + }, + }, + fcmTokens: map[string]domain.FCMToken{}, + } +} + +func NewMockUserRepositoryWithError(field string, err error) *MockUserRepository { + m := NewMockUserRepository() + switch field { + case "getUser": + m.getUserErr = err + case "upsert": + m.upsertErr = err + case "upsertFCMToken": + m.upsertFCMTokenErr = err + } + return m +} + +func NewMockUserRepositoryEmpty() *MockUserRepository { + return &MockUserRepository{ + users: map[string]domain.User{}, + getUserErr: domain.ErrUserNotFound, + fcmTokens: map[string]domain.FCMToken{}, + } +} + +func (m *MockUserRepository) GetUser(id string) (*domain.User, error) { + if m.getUserErr != nil { + return nil, m.getUserErr + } + user, ok := m.users[id] + if !ok { + return nil, domain.ErrUserNotFound + } + return &user, nil +} + +func (m *MockUserRepository) UpsertUser(id string, req domain.UserRequest) (*domain.User, error) { + if m.upsertErr != nil { + return nil, m.upsertErr + } + user := domain.User{ + ID: id, + Email: req.Email, + Grade: req.Grade, + Course: req.Course, + Class: req.Class, + } + m.users[id] = user + return &user, nil +} + +func (m *MockUserRepository) UpsertFCMToken(userID string, req domain.FCMTokenRequest) (*domain.FCMToken, error) { + if m.upsertFCMTokenErr != nil { + return nil, m.upsertFCMTokenErr + } + + now := time.Now().UTC() + token := domain.FCMToken{ + Token: req.Token, + CreatedAt: now, + UpdatedAt: now, + } + if existing, ok := m.fcmTokens[userID]; ok { + token.CreatedAt = existing.CreatedAt + } + m.fcmTokens[userID] = token + + return &token, nil +} diff --git a/internal/modules/app/service/academic.go b/internal/modules/app/service/academic.go new file mode 100644 index 0000000..03aee8b --- /dev/null +++ b/internal/modules/app/service/academic.go @@ -0,0 +1,83 @@ +package service + +import ( + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" +) + +type AcademicRepository interface { + GetFaculties() ([]domain.Faculty, error) + GetFaculty(id string) (*domain.Faculty, error) + GetSubjects(query domain.SubjectQuery) ([]domain.Subject, error) + GetSubject(id string) (*domain.Subject, error) + GetCourseRegistrations(userID string, semesters []domain.CourseSemester, year *int) ([]domain.CourseRegistration, error) + CreateCourseRegistration(userID string, subjectID string) (*domain.CourseRegistration, error) + DeleteCourseRegistration(id string) error + GetTimetableItems(query domain.TimetableItemQuery) ([]domain.TimetableItem, error) + GetPersonalCalendarItems(userID string, dates []time.Time) ([]domain.PersonalCalendarItem, error) + GetCancelledClasses(query domain.CancelledClassQuery) ([]domain.CancelledClass, error) + GetMakeupClasses(query domain.MakeupClassQuery) ([]domain.MakeupClass, error) + GetRoomChanges(query domain.RoomChangeQuery) ([]domain.RoomChange, error) + GetReservations(query domain.ReservationQuery) ([]domain.Reservation, error) +} + +type AcademicService struct { + repository AcademicRepository +} + +func NewAcademicService(repository AcademicRepository) *AcademicService { + return &AcademicService{repository: repository} +} + +func (s *AcademicService) GetFaculties() ([]domain.Faculty, error) { + return s.repository.GetFaculties() +} + +func (s *AcademicService) GetFaculty(id string) (*domain.Faculty, error) { + return s.repository.GetFaculty(id) +} + +func (s *AcademicService) GetSubjects(query domain.SubjectQuery) ([]domain.Subject, error) { + return s.repository.GetSubjects(query) +} + +func (s *AcademicService) GetSubject(id string) (*domain.Subject, error) { + return s.repository.GetSubject(id) +} + +func (s *AcademicService) GetCourseRegistrations(userID string, semesters []domain.CourseSemester, year *int) ([]domain.CourseRegistration, error) { + return s.repository.GetCourseRegistrations(userID, semesters, year) +} + +func (s *AcademicService) CreateCourseRegistration(userID string, subjectID string) (*domain.CourseRegistration, error) { + return s.repository.CreateCourseRegistration(userID, subjectID) +} + +func (s *AcademicService) DeleteCourseRegistration(id string) error { + return s.repository.DeleteCourseRegistration(id) +} + +func (s *AcademicService) GetTimetableItems(query domain.TimetableItemQuery) ([]domain.TimetableItem, error) { + return s.repository.GetTimetableItems(query) +} + +func (s *AcademicService) GetPersonalCalendarItems(userID string, dates []time.Time) ([]domain.PersonalCalendarItem, error) { + return s.repository.GetPersonalCalendarItems(userID, dates) +} + +func (s *AcademicService) GetCancelledClasses(query domain.CancelledClassQuery) ([]domain.CancelledClass, error) { + return s.repository.GetCancelledClasses(query) +} + +func (s *AcademicService) GetMakeupClasses(query domain.MakeupClassQuery) ([]domain.MakeupClass, error) { + return s.repository.GetMakeupClasses(query) +} + +func (s *AcademicService) GetRoomChanges(query domain.RoomChangeQuery) ([]domain.RoomChange, error) { + return s.repository.GetRoomChanges(query) +} + +func (s *AcademicService) GetReservations(query domain.ReservationQuery) ([]domain.Reservation, error) { + return s.repository.GetReservations(query) +} diff --git a/internal/modules/app/service/academic_test.go b/internal/modules/app/service/academic_test.go new file mode 100644 index 0000000..deada2a --- /dev/null +++ b/internal/modules/app/service/academic_test.go @@ -0,0 +1,282 @@ +package service + +import ( + "testing" + + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAcademicService_GetFaculties(t *testing.T) { + tests := []struct { + name string + repo AcademicRepository + validate func(t *testing.T, faculties []domain.Faculty, err error) + }{ + { + name: "正常系: 教員一覧を取得できる", + repo: repository.NewMockAcademicRepository(), + validate: func(t *testing.T, faculties []domain.Faculty, err error) { + require.NoError(t, err) + assert.Len(t, faculties, 1) + assert.Equal(t, "f1", faculties[0].ID) + assert.Equal(t, "田中太郎", faculties[0].Name) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + faculties, err := svc.GetFaculties() + tt.validate(t, faculties, err) + }) + } +} + +func TestAcademicService_GetFaculty(t *testing.T) { + tests := []struct { + name string + repo AcademicRepository + id string + validate func(t *testing.T, faculty *domain.Faculty, err error) + }{ + { + name: "正常系: 教員を取得できる", + repo: repository.NewMockAcademicRepository(), + id: "f1", + validate: func(t *testing.T, faculty *domain.Faculty, err error) { + require.NoError(t, err) + assert.Equal(t, "f1", faculty.ID) + assert.Equal(t, "田中太郎", faculty.Name) + }, + }, + { + name: "異常系: 存在しないIDの場合エラーを返す", + repo: repository.NewMockAcademicRepository(), + id: "nonexistent", + validate: func(t *testing.T, faculty *domain.Faculty, err error) { + require.Error(t, err) + assert.Nil(t, faculty) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + faculty, err := svc.GetFaculty(tt.id) + tt.validate(t, faculty, err) + }) + } +} + +func TestAcademicService_GetSubjects(t *testing.T) { + tests := []struct { + name string + repo AcademicRepository + query domain.SubjectQuery + validate func(t *testing.T, subjects []domain.Subject, err error) + }{ + { + name: "正常系: 科目一覧を取得しFaculty情報が補完される", + repo: repository.NewMockAcademicRepository(), + query: domain.SubjectQuery{}, + validate: func(t *testing.T, subjects []domain.Subject, err error) { + require.NoError(t, err) + assert.Len(t, subjects, 1) + assert.Equal(t, "s1", subjects[0].ID) + assert.Equal(t, "プログラミング基礎", subjects[0].Name) + require.Len(t, subjects[0].Faculties, 1) + assert.Equal(t, "田中太郎", subjects[0].Faculties[0].Faculty.Name) + assert.Equal(t, "tanaka@example.com", subjects[0].Faculties[0].Faculty.Email) + }, + }, + { + name: "異常系: GetSubjectsがエラーを返す場合エラーを返す", + repo: repository.NewMockAcademicRepositoryWithError("getSubjects", assert.AnError), + query: domain.SubjectQuery{}, + validate: func(t *testing.T, subjects []domain.Subject, err error) { + require.Error(t, err) + assert.Nil(t, subjects) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + subjects, err := svc.GetSubjects(tt.query) + tt.validate(t, subjects, err) + }) + } +} + +func TestAcademicService_GetSubject(t *testing.T) { + tests := []struct { + name string + repo AcademicRepository + id string + validate func(t *testing.T, subject *domain.Subject, err error) + }{ + { + name: "正常系: 科目を取得しFaculty情報が補完される", + repo: repository.NewMockAcademicRepository(), + id: "s1", + validate: func(t *testing.T, subject *domain.Subject, err error) { + require.NoError(t, err) + assert.Equal(t, "s1", subject.ID) + assert.Equal(t, "プログラミング基礎", subject.Name) + require.Len(t, subject.Faculties, 1) + assert.Equal(t, "田中太郎", subject.Faculties[0].Faculty.Name) + assert.Equal(t, "tanaka@example.com", subject.Faculties[0].Faculty.Email) + }, + }, + { + name: "異常系: 存在しないIDの場合エラーを返す", + repo: repository.NewMockAcademicRepository(), + id: "nonexistent", + validate: func(t *testing.T, subject *domain.Subject, err error) { + require.Error(t, err) + assert.Nil(t, subject) + }, + }, + { + name: "異常系: GetSubjectがエラーを返す場合エラーを返す", + repo: repository.NewMockAcademicRepositoryWithError("getSubject", assert.AnError), + id: "s1", + validate: func(t *testing.T, subject *domain.Subject, err error) { + require.Error(t, err) + assert.Nil(t, subject) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + subject, err := svc.GetSubject(tt.id) + tt.validate(t, subject, err) + }) + } +} + +func TestAcademicService_GetCourseRegistrations(t *testing.T) { + year := 2026 + + tests := []struct { + name string + repo AcademicRepository + userID string + semesters []domain.CourseSemester + year *int + validate func(t *testing.T, registrations []domain.CourseRegistration, err error) + }{ + { + name: "正常系: 履修登録一覧を取得できる", + repo: repository.NewMockAcademicRepository(), + userID: "user1", + semesters: []domain.CourseSemester{domain.CourseSemesterQ1}, + year: &year, + validate: func(t *testing.T, registrations []domain.CourseRegistration, err error) { + require.NoError(t, err) + assert.Len(t, registrations, 1) + assert.Equal(t, "cr1", registrations[0].ID) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAcademicRepositoryWithError("getRegistrations", assert.AnError), + userID: "user1", + semesters: []domain.CourseSemester{domain.CourseSemesterQ1}, + year: &year, + validate: func(t *testing.T, registrations []domain.CourseRegistration, err error) { + require.Error(t, err) + assert.Nil(t, registrations) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + registrations, err := svc.GetCourseRegistrations(tt.userID, tt.semesters, tt.year) + tt.validate(t, registrations, err) + }) + } +} + +func TestAcademicService_CreateCourseRegistration(t *testing.T) { + tests := []struct { + name string + repo AcademicRepository + userID string + subjectID string + validate func(t *testing.T, registration *domain.CourseRegistration, err error) + }{ + { + name: "正常系: 履修登録を作成できる", + repo: repository.NewMockAcademicRepository(), + userID: "user1", + subjectID: "s1", + validate: func(t *testing.T, registration *domain.CourseRegistration, err error) { + require.NoError(t, err) + assert.Equal(t, "cr-new", registration.ID) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAcademicRepositoryWithError("create", assert.AnError), + userID: "user1", + subjectID: "s1", + validate: func(t *testing.T, registration *domain.CourseRegistration, err error) { + require.Error(t, err) + assert.Nil(t, registration) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + registration, err := svc.CreateCourseRegistration(tt.userID, tt.subjectID) + tt.validate(t, registration, err) + }) + } +} + +func TestAcademicService_DeleteCourseRegistration(t *testing.T) { + tests := []struct { + name string + repo AcademicRepository + id string + validate func(t *testing.T, err error) + }{ + { + name: "正常系: 履修登録を削除できる", + repo: repository.NewMockAcademicRepository(), + id: "cr1", + validate: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAcademicRepositoryWithError("delete", assert.AnError), + id: "cr1", + validate: func(t *testing.T, err error) { + require.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAcademicService(tt.repo) + err := svc.DeleteCourseRegistration(tt.id) + tt.validate(t, err) + }) + } +} diff --git a/internal/modules/app/service/announcement.go b/internal/modules/app/service/announcement.go new file mode 100644 index 0000000..b3c9e3f --- /dev/null +++ b/internal/modules/app/service/announcement.go @@ -0,0 +1,45 @@ +package service + +import "github.com/fun-dotto/server/internal/modules/app/domain" + +type AnnouncementRepository interface { + GetAnnouncements(query domain.AnnouncementQuery) ([]domain.Announcement, error) + GetAnnouncement(id string) (*domain.Announcement, error) + CreateAnnouncement(req domain.AnnouncementRequest) (*domain.Announcement, error) + UpdateAnnouncement(id string, req domain.AnnouncementRequest) (*domain.Announcement, error) + DeleteAnnouncement(id string) error +} + +type AnnouncementService struct { + announcementRepository AnnouncementRepository +} + +func NewAnnouncementService(announcementRepository AnnouncementRepository) *AnnouncementService { + return &AnnouncementService{announcementRepository: announcementRepository} +} + +func (s *AnnouncementService) GetAnnouncements() ([]domain.Announcement, error) { + sortByDate := domain.SortDirectionDesc + filterIsActive := true + query := domain.AnnouncementQuery{ + SortByDate: &sortByDate, + FilterIsActive: &filterIsActive, + } + return s.announcementRepository.GetAnnouncements(query) +} + +func (s *AnnouncementService) GetAnnouncement(id string) (*domain.Announcement, error) { + return s.announcementRepository.GetAnnouncement(id) +} + +func (s *AnnouncementService) CreateAnnouncement(req domain.AnnouncementRequest) (*domain.Announcement, error) { + return s.announcementRepository.CreateAnnouncement(req) +} + +func (s *AnnouncementService) UpdateAnnouncement(id string, req domain.AnnouncementRequest) (*domain.Announcement, error) { + return s.announcementRepository.UpdateAnnouncement(id, req) +} + +func (s *AnnouncementService) DeleteAnnouncement(id string) error { + return s.announcementRepository.DeleteAnnouncement(id) +} diff --git a/internal/modules/app/service/announcement_test.go b/internal/modules/app/service/announcement_test.go new file mode 100644 index 0000000..43593fa --- /dev/null +++ b/internal/modules/app/service/announcement_test.go @@ -0,0 +1,246 @@ +package service + +import ( + "testing" + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnnouncementService_GetAnnouncements(t *testing.T) { + tests := []struct { + name string + repo AnnouncementRepository + validate func(t *testing.T, announcements []domain.Announcement, err error) + }{ + { + name: "正常系: お知らせ一覧を取得できる", + repo: repository.NewMockAnnouncementRepository(), + validate: func(t *testing.T, announcements []domain.Announcement, err error) { + require.NoError(t, err) + assert.Len(t, announcements, 1) + assert.Equal(t, "1", announcements[0].ID) + assert.Equal(t, "Announcement 1", announcements[0].Title) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAnnouncementRepositoryWithError("getAnnouncements", assert.AnError), + validate: func(t *testing.T, announcements []domain.Announcement, err error) { + require.Error(t, err) + assert.Nil(t, announcements) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAnnouncementService(tt.repo) + announcements, err := svc.GetAnnouncements() + tt.validate(t, announcements, err) + }) + } +} + +func TestAnnouncementService_GetAnnouncement(t *testing.T) { + tests := []struct { + name string + repo AnnouncementRepository + id string + validate func(t *testing.T, announcement *domain.Announcement, err error) + }{ + { + name: "正常系: お知らせを取得できる", + repo: repository.NewMockAnnouncementRepository(), + id: "1", + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.NoError(t, err) + assert.Equal(t, "1", announcement.ID) + assert.Equal(t, "Announcement 1", announcement.Title) + }, + }, + { + name: "異常系: 存在しないIDの場合エラーを返す", + repo: repository.NewMockAnnouncementRepository(), + id: "nonexistent", + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.Error(t, err) + assert.Nil(t, announcement) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAnnouncementRepositoryWithError("getAnnouncement", assert.AnError), + id: "1", + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.Error(t, err) + assert.Nil(t, announcement) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAnnouncementService(tt.repo) + announcement, err := svc.GetAnnouncement(tt.id) + tt.validate(t, announcement, err) + }) + } +} + +func TestAnnouncementService_CreateAnnouncement(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + repo AnnouncementRepository + req domain.AnnouncementRequest + validate func(t *testing.T, announcement *domain.Announcement, err error) + }{ + { + name: "正常系: お知らせを作成できる", + repo: repository.NewMockAnnouncementRepository(), + req: domain.AnnouncementRequest{ + Title: "New Announcement", + AvailableFrom: now, + URL: "https://example.com/new", + }, + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.NoError(t, err) + assert.Equal(t, "New Announcement", announcement.Title) + assert.Equal(t, "https://example.com/new", announcement.URL) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAnnouncementRepositoryWithError("create", assert.AnError), + req: domain.AnnouncementRequest{ + Title: "New Announcement", + AvailableFrom: now, + URL: "https://example.com/new", + }, + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.Error(t, err) + assert.Nil(t, announcement) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAnnouncementService(tt.repo) + announcement, err := svc.CreateAnnouncement(tt.req) + tt.validate(t, announcement, err) + }) + } +} + +func TestAnnouncementService_UpdateAnnouncement(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + repo AnnouncementRepository + id string + req domain.AnnouncementRequest + validate func(t *testing.T, announcement *domain.Announcement, err error) + }{ + { + name: "正常系: お知らせを更新できる", + repo: repository.NewMockAnnouncementRepository(), + id: "1", + req: domain.AnnouncementRequest{ + Title: "Updated Announcement", + AvailableFrom: now, + URL: "https://example.com/updated", + }, + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.NoError(t, err) + assert.Equal(t, "1", announcement.ID) + assert.Equal(t, "Updated Announcement", announcement.Title) + assert.Equal(t, "https://example.com/updated", announcement.URL) + }, + }, + { + name: "異常系: 存在しないIDの場合エラーを返す", + repo: repository.NewMockAnnouncementRepository(), + id: "nonexistent", + req: domain.AnnouncementRequest{ + Title: "Updated", + AvailableFrom: now, + URL: "https://example.com", + }, + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.Error(t, err) + assert.Nil(t, announcement) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAnnouncementRepositoryWithError("update", assert.AnError), + id: "1", + req: domain.AnnouncementRequest{ + Title: "Updated", + AvailableFrom: now, + URL: "https://example.com", + }, + validate: func(t *testing.T, announcement *domain.Announcement, err error) { + require.Error(t, err) + assert.Nil(t, announcement) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAnnouncementService(tt.repo) + announcement, err := svc.UpdateAnnouncement(tt.id, tt.req) + tt.validate(t, announcement, err) + }) + } +} + +func TestAnnouncementService_DeleteAnnouncement(t *testing.T) { + tests := []struct { + name string + repo AnnouncementRepository + id string + validate func(t *testing.T, err error) + }{ + { + name: "正常系: お知らせを削除できる", + repo: repository.NewMockAnnouncementRepository(), + id: "1", + validate: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "異常系: 存在しないIDの場合エラーを返す", + repo: repository.NewMockAnnouncementRepository(), + id: "nonexistent", + validate: func(t *testing.T, err error) { + require.Error(t, err) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockAnnouncementRepositoryWithError("delete", assert.AnError), + id: "1", + validate: func(t *testing.T, err error) { + require.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewAnnouncementService(tt.repo) + err := svc.DeleteAnnouncement(tt.id) + tt.validate(t, err) + }) + } +} diff --git a/internal/modules/app/service/funch.go b/internal/modules/app/service/funch.go new file mode 100644 index 0000000..aebe75e --- /dev/null +++ b/internal/modules/app/service/funch.go @@ -0,0 +1,19 @@ +package service + +import "github.com/fun-dotto/server/internal/modules/app/domain" + +type FunchRepository interface { + GetMenuItems(query domain.MenuItemQuery) ([]domain.MenuItem, error) +} + +type FunchService struct { + repository FunchRepository +} + +func NewFunchService(repository FunchRepository) *FunchService { + return &FunchService{repository: repository} +} + +func (s *FunchService) GetMenuItems(query domain.MenuItemQuery) ([]domain.MenuItem, error) { + return s.repository.GetMenuItems(query) +} diff --git a/internal/modules/app/service/user.go b/internal/modules/app/service/user.go new file mode 100644 index 0000000..409f738 --- /dev/null +++ b/internal/modules/app/service/user.go @@ -0,0 +1,29 @@ +package service + +import "github.com/fun-dotto/server/internal/modules/app/domain" + +type UserRepository interface { + GetUser(id string) (*domain.User, error) + UpsertUser(id string, req domain.UserRequest) (*domain.User, error) + UpsertFCMToken(userID string, req domain.FCMTokenRequest) (*domain.FCMToken, error) +} + +type UserService struct { + repository UserRepository +} + +func NewUserService(repository UserRepository) *UserService { + return &UserService{repository: repository} +} + +func (s *UserService) GetUser(id string) (*domain.User, error) { + return s.repository.GetUser(id) +} + +func (s *UserService) UpsertUser(id string, req domain.UserRequest) (*domain.User, error) { + return s.repository.UpsertUser(id, req) +} + +func (s *UserService) UpsertFCMToken(userID string, req domain.FCMTokenRequest) (*domain.FCMToken, error) { + return s.repository.UpsertFCMToken(userID, req) +} diff --git a/internal/modules/app/service/user_test.go b/internal/modules/app/service/user_test.go new file mode 100644 index 0000000..5ea3980 --- /dev/null +++ b/internal/modules/app/service/user_test.go @@ -0,0 +1,175 @@ +package service + +import ( + "testing" + "time" + + "github.com/fun-dotto/server/internal/modules/app/domain" + "github.com/fun-dotto/server/internal/modules/app/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserService_GetUser(t *testing.T) { + tests := []struct { + name string + repo UserRepository + userID string + validate func(t *testing.T, user *domain.User, err error) + }{ + { + name: "正常系: ユーザーを取得できる", + repo: repository.NewMockUserRepository(), + userID: "user1", + validate: func(t *testing.T, user *domain.User, err error) { + require.NoError(t, err) + assert.Equal(t, "user1", user.ID) + assert.Equal(t, "user1@example.com", user.Email) + assert.NotNil(t, user.Grade) + assert.NotNil(t, user.Course) + assert.NotNil(t, user.Class) + }, + }, + { + name: "異常系: 存在しないユーザーIDの場合エラーを返す", + repo: repository.NewMockUserRepository(), + userID: "nonexistent", + validate: func(t *testing.T, user *domain.User, err error) { + require.Error(t, err) + assert.Nil(t, user) + assert.ErrorIs(t, err, domain.ErrUserNotFound) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockUserRepositoryWithError("getUser", assert.AnError), + userID: "user1", + validate: func(t *testing.T, user *domain.User, err error) { + require.Error(t, err) + assert.Nil(t, user) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewUserService(tt.repo) + user, err := svc.GetUser(tt.userID) + tt.validate(t, user, err) + }) + } +} + +func TestUserService_UpsertUser(t *testing.T) { + grade := domain.GradeB1 + course := domain.CourseInformationDesign + class := domain.ClassB + + tests := []struct { + name string + repo UserRepository + userID string + req domain.UserRequest + validate func(t *testing.T, user *domain.User, err error) + }{ + { + name: "正常系: 新規ユーザーを作成できる", + repo: repository.NewMockUserRepositoryEmpty(), + userID: "newuser", + req: domain.UserRequest{ + Email: "new@example.com", + Grade: &grade, + Course: &course, + Class: &class, + }, + validate: func(t *testing.T, user *domain.User, err error) { + require.NoError(t, err) + assert.Equal(t, "newuser", user.ID) + assert.Equal(t, "new@example.com", user.Email) + require.NotNil(t, user.Grade) + assert.Equal(t, grade, *user.Grade) + require.NotNil(t, user.Course) + assert.Equal(t, course, *user.Course) + require.NotNil(t, user.Class) + assert.Equal(t, class, *user.Class) + }, + }, + { + name: "正常系: 既存ユーザーを更新できる", + repo: repository.NewMockUserRepository(), + userID: "user1", + req: domain.UserRequest{ + Email: "updated@example.com", + Grade: &grade, + Course: &course, + Class: &class, + }, + validate: func(t *testing.T, user *domain.User, err error) { + require.NoError(t, err) + assert.Equal(t, "user1", user.ID) + assert.Equal(t, "updated@example.com", user.Email) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockUserRepositoryWithError("upsert", assert.AnError), + userID: "user1", + req: domain.UserRequest{ + Email: "test@example.com", + }, + validate: func(t *testing.T, user *domain.User, err error) { + require.Error(t, err) + assert.Nil(t, user) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewUserService(tt.repo) + user, err := svc.UpsertUser(tt.userID, tt.req) + tt.validate(t, user, err) + }) + } +} + +func TestUserService_UpsertFCMToken(t *testing.T) { + tests := []struct { + name string + repo UserRepository + userID string + req domain.FCMTokenRequest + validate func(t *testing.T, token *domain.FCMToken, err error) + }{ + { + name: "正常系: FCMトークンを作成・更新できる", + repo: repository.NewMockUserRepository(), + userID: "user1", + req: domain.FCMTokenRequest{Token: "fcm-token-1"}, + validate: func(t *testing.T, token *domain.FCMToken, err error) { + require.NoError(t, err) + assert.Equal(t, "fcm-token-1", token.Token) + assert.WithinDuration(t, time.Now(), token.CreatedAt, time.Minute) + assert.WithinDuration(t, time.Now(), token.UpdatedAt, time.Minute) + }, + }, + { + name: "異常系: リポジトリがエラーを返す場合エラーを返す", + repo: repository.NewMockUserRepositoryWithError("upsertFCMToken", assert.AnError), + userID: "user1", + req: domain.FCMTokenRequest{Token: "fcm-token-1"}, + validate: func(t *testing.T, token *domain.FCMToken, err error) { + require.Error(t, err) + assert.Nil(t, token) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewUserService(tt.repo) + token, err := svc.UpsertFCMToken(tt.userID, tt.req) + tt.validate(t, token, err) + }) + } +} diff --git a/internal/shared/apiclient/clients.go b/internal/shared/apiclient/clients.go new file mode 100644 index 0000000..c3de5fa --- /dev/null +++ b/internal/shared/apiclient/clients.go @@ -0,0 +1,108 @@ +// Package apiclient は BFF (app / admin) が呼び出す各ドメイン API の +// HTTP クライアントをまとめて初期化するパッケージ。 +// Cloud Run 間呼び出しのため ID トークン付きの HTTP クライアントを利用する。 +package apiclient + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + academic_api "github.com/fun-dotto/server/gen/academic" + announcement_api "github.com/fun-dotto/server/gen/announcement" + funch_api "github.com/fun-dotto/server/gen/funch" + user_api "github.com/fun-dotto/server/gen/user" + "google.golang.org/api/idtoken" +) + +const httpClientTimeout = 30 * time.Second + +// ExternalClients 外部APIクライアントをまとめて管理 +type ExternalClients struct { + Academic *academic_api.ClientWithResponses + Announcement *announcement_api.ClientWithResponses + Funch *funch_api.ClientWithResponses + User *user_api.ClientWithResponses +} + +// NewExternalClients 全ての外部APIクライアントを初期化 +func NewExternalClients(ctx context.Context) (*ExternalClients, error) { + academicURL, err := requireURL("ACADEMIC_API_URL") + if err != nil { + return nil, err + } + academicHTTP, err := newAuthHTTPClient(ctx, academicURL) + if err != nil { + return nil, fmt.Errorf("academic client: %w", err) + } + academic, err := academic_api.NewClientWithResponses(academicURL, academic_api.WithHTTPClient(academicHTTP)) + if err != nil { + return nil, fmt.Errorf("academic client: %w", err) + } + + announcementURL, err := requireURL("ANNOUNCEMENT_API_URL") + if err != nil { + return nil, err + } + announcementHTTP, err := newAuthHTTPClient(ctx, announcementURL) + if err != nil { + return nil, fmt.Errorf("announcement client: %w", err) + } + announcement, err := announcement_api.NewClientWithResponses(announcementURL, announcement_api.WithHTTPClient(announcementHTTP)) + if err != nil { + return nil, fmt.Errorf("announcement client: %w", err) + } + + funchURL, err := requireURL("FUNCH_API_URL") + if err != nil { + return nil, err + } + funchHTTP, err := newAuthHTTPClient(ctx, funchURL) + if err != nil { + return nil, fmt.Errorf("funch client: %w", err) + } + funch, err := funch_api.NewClientWithResponses(funchURL, funch_api.WithHTTPClient(funchHTTP)) + if err != nil { + return nil, fmt.Errorf("funch client: %w", err) + } + + userURL, err := requireURL("USER_API_URL") + if err != nil { + return nil, err + } + userHTTP, err := newAuthHTTPClient(ctx, userURL) + if err != nil { + return nil, fmt.Errorf("user client: %w", err) + } + user, err := user_api.NewClientWithResponses(userURL, user_api.WithHTTPClient(userHTTP)) + if err != nil { + return nil, fmt.Errorf("user client: %w", err) + } + + return &ExternalClients{ + Academic: academic, + Announcement: announcement, + Funch: funch, + User: user, + }, nil +} + +func requireURL(envKey string) (string, error) { + url := os.Getenv(envKey) + if url == "" { + return "", fmt.Errorf("%s is required", envKey) + } + return url, nil +} + +// newAuthHTTPClient Google Cloud認証付きHTTPクライアントを作成 +func newAuthHTTPClient(ctx context.Context, targetURL string) (*http.Client, error) { + client, err := idtoken.NewClient(ctx, targetURL) + if err != nil { + return nil, fmt.Errorf("failed to create auth client: %w", err) + } + client.Timeout = httpClientTimeout + return client, nil +} diff --git a/mise.toml b/mise.toml index 703109c..5096579 100644 --- a/mise.toml +++ b/mise.toml @@ -32,6 +32,10 @@ run = [ "rm -rf ./bin", "mkdir -p ./bin", "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/academic-api ./cmd/academic-api", + "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/admin-api ./cmd/admin-api", + "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/announcement-api ./cmd/announcement-api", + "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/app-api ./cmd/app-api", + "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/user-api ./cmd/user-api", "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/migrate ./cmd/migrate", "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/apply-table-privileges ./cmd/apply-table-privileges", "go build -tags timetzdata -trimpath -ldflags='-s -w' -o ./bin/build-class-change-notifications-job ./cmd/build-class-change-notifications-job", @@ -53,6 +57,14 @@ run = "go run ./cmd/migrate" description = "cmd/academic-api を起動する" run = "go run ./cmd/academic-api" +[tasks.admin-api] +description = "cmd/admin-api (管理画面向け BFF) を起動する" +run = "go run ./cmd/admin-api" + +[tasks.app-api] +description = "cmd/app-api (モバイルアプリ向け BFF) を起動する" +run = "go run ./cmd/app-api" + [tasks.test] description = "Run all unit tests" run = "go test -v ./..."