diff --git a/.golangci.yml b/.golangci.yml index 274175e0..985dbb21 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -40,19 +40,19 @@ linters-settings: - switch - map - funlen: - # Checks the number of lines in a function. - # If lower than 0, disable the check. - # Default: 60 - lines: 150 - # Checks the number of statements in a function. - # If lower than 0, disable the check. - # Default: 40 - statements: 100 - - # Ignore comments when counting lines. - # Default false - ignore-comments: true + # funlen: + # # Checks the number of lines in a function. + # # If lower than 0, disable the check. + # # Default: 60 + # lines: 150 + # # Checks the number of statements in a function. + # # If lower than 0, disable the check. + # # Default: 40 + # statements: 100 + # + # # Ignore comments when counting lines. + # # Default false + # ignore-comments: true gocognit: # Minimal code complexity to report @@ -470,7 +470,7 @@ linters: - exhaustive # checks exhaustiveness of enum switch statements - exportloopref # checks for pointers to enclosing loop variables - forbidigo # forbids identifiers - - funlen # tool for detection of long functions + # - funlen # tool for detection of long functions - gocheckcompilerdirectives - gochecknoinits # checks that no init functions are present in Go code - gocognit # computes and checks the cognitive complexity of functions @@ -585,7 +585,7 @@ issues: - govet - bodyclose - dupl - - funlen + # - funlen - goconst - gosec - noctx diff --git a/Makefile b/Makefile index 17b4611d..2380ec1c 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,7 @@ clean: make docs:clean compile: + go generate ./... go build -o ${BUILD_DIR}/${SERVER_BINARY_NAME} ./cmd/server build: diff --git a/api/common.go b/api/common.go index 4efbe3bc..85d22909 100644 --- a/api/common.go +++ b/api/common.go @@ -3,8 +3,16 @@ package pnd import ( "encoding/json" "net/http" + + "github.com/google/uuid" ) +type CursorPaginatedView[T interface{}] struct { + Items []T `json:"items"` + Prev uuid.NullUUID `json:"prev"` + Next uuid.NullUUID `json:"next"` +} + type PaginatedView[T interface{}] struct { Page int `json:"page"` Size int `json:"size"` diff --git a/cmd/server/handler/event_handler.go b/cmd/server/handler/event_handler.go new file mode 100644 index 00000000..f2570817 --- /dev/null +++ b/cmd/server/handler/event_handler.go @@ -0,0 +1,193 @@ +package handler + +import ( + "log" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + pnd "github.com/pet-sitter/pets-next-door-api/api" + "github.com/pet-sitter/pets-next-door-api/internal/domain/event" + databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" + "github.com/pet-sitter/pets-next-door-api/internal/service" +) + +type EventHandler struct { + authService service.AuthService + eventService service.EventService +} + +func NewEventHandler( + authService service.AuthService, + eventService service.EventService, +) *EventHandler { + return &EventHandler{ + authService: authService, + eventService: eventService, + } +} + +// FindEvents godoc +// @Summary 이벤트를 조회합니다. +// @Description +// @Tags events +// @Accept json +// @Produce json +// @Param author_id query string false "작성자 ID" +// @Param prev query int false "이전 페이지" +// @Param next query int false "다음 페이지" +// @Param size query int false "페이지 사이즈" default(20) +// @Success 200 {object} pnd.CursorPaginatedView[event.View] +// @Router /events [get] +func (h *EventHandler) FindEvents(c echo.Context) error { + prev, next, size, err := pnd.ParseCursorPaginationQueries(c, 20) + if err != nil { + return err + } + authorID, err := pnd.ParseOptionalUUIDQuery(c, "author_id") + if err != nil { + return err + } + + ctx := c.Request().Context() + events, err := h.eventService.FindEvents(ctx, databasegen.FindEventsParams{ + AuthorID: authorID, + Prev: prev, + Next: next, + Limit: int32(size), + }) + if err != nil { + return err + } + + items := make([]event.ShortTermView, len(events)) + for i, e := range events { + items[i] = event.ToShortTermView(e) + } + return c.JSON( + http.StatusOK, + pnd.CursorPaginatedView[event.ShortTermView]{ + Items: items, + }, + ) +} + +// FindEventByID godoc +// @Summary ID로 이벤트를 조회합니다. +// @Description +// @Tags events +// @Produce json +// @Param id path string true "이벤트 ID" +// @Success 200 {object} event.View +// @Router /events/{id} [get] +func (h *EventHandler) FindEventByID(c echo.Context) error { + id, err := pnd.ParseIDFromPath(c, "id") + if err != nil { + return err + } + + ctx := c.Request().Context() + found, err := h.eventService.FindEvent( + ctx, + databasegen.FindEventParams{ID: uuid.NullUUID{UUID: id, Valid: true}}, + ) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, event.ToShortTermView(found)) +} + +// CreateEvent godoc +// @Summary 이벤트를 생성합니다. +// @Description +// @Tags events +// @Accept json +// @Produce json +// @Param request body event.CreateRequest true "이벤트 생성 요청" +// @Security FirebaseAuth +// @Success 201 {object} event.View +// @Router /events [post] +func (h *EventHandler) CreateEvent(c echo.Context) error { + foundUser, err := h.authService.VerifyAuthAndGetUser( + c.Request().Context(), + c.Request().Header.Get("Authorization"), + ) + if err != nil { + return err + } + authorID := foundUser.ID + + var reqBody event.CreateRequest + if err := pnd.ParseBody(c, &reqBody); err != nil { + return err + } + + ctx := c.Request().Context() + created, err := h.eventService.CreateEvent(ctx, authorID, reqBody) + if err != nil { + return err + } + + return c.JSON(http.StatusCreated, event.ToShortTermView(created)) +} + +// UpdateEvent godoc +// @Summary 이벤트를 수정합니다. +// @Description +// @Tags events +// @Accept json +// @Produce json +// @Security FirebaseAuth +// @Param request body event.UpdateRequest true "이벤트 수정 요청" +// @Success 200 +// @Router /events [put] +func (h *EventHandler) UpdateEvent(c echo.Context) error { + foundUser, err := h.authService.VerifyAuthAndGetUser( + c.Request().Context(), + c.Request().Header.Get("Authorization"), + ) + if err != nil { + return err + } + uid := foundUser.FirebaseUID + + var reqBody event.UpdateRequest + if err := pnd.ParseBody(c, &reqBody); err != nil { + return err + } + + log.Printf("uid: %s, reqBody: %+v", uid, reqBody) + // TODO: Implement update event logic + + return c.JSON(http.StatusOK, nil) +} + +// DeleteEvent godoc +// @Summary 이벤트를 삭제합니다. +// @Description +// @Tags events +// @Security FirebaseAuth +// @Param id path string true "이벤트 ID" +// @Success 200 +// @Router /events/{id} [delete] +func (h *EventHandler) DeleteEvent(c echo.Context) error { + foundUser, err := h.authService.VerifyAuthAndGetUser( + c.Request().Context(), + c.Request().Header.Get("Authorization"), + ) + if err != nil { + return err + } + uid := foundUser.FirebaseUID + + id, err := pnd.ParseIDFromPath(c, "id") + if err != nil { + return err + } + + log.Printf("uid: %s, id: %s", uid, id) + // TODO: Implement delete event logic + + return c.JSON(http.StatusOK, nil) +} diff --git a/cmd/server/router.go b/cmd/server/router.go index 68969ae9..a17eed20 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -58,6 +58,7 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { breedService := service.NewBreedService(db) sosPostService := service.NewSOSPostService(db) conditionService := service.NewSOSConditionService(db) + eventService := service.NewEventService(db, userService, mediaService) chatService := service.NewChatService(db) // Initialize handlers @@ -68,6 +69,7 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { sosPostHandler := handler.NewSOSPostHandler(*sosPostService, authService) conditionHandler := handler.NewConditionHandler(*conditionService) chatHandler := handler.NewChatHandler(authService, *chatService) + eventHandler := handler.NewEventHandler(authService, *eventService) // // InMemoryStateManager는 클라이언트와 채팅방의 상태를 메모리에 저장하고 관리합니다. // // 이 메서드는 단순하고 빠르며 테스트 목적으로 적합합니다. @@ -170,6 +172,15 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { postAPIGroup.GET("/sos/conditions", conditionHandler.FindConditions) } + eventAPIGroup := apiRouteGroup.Group("/events") + { + eventAPIGroup.GET("", eventHandler.FindEvents) + eventAPIGroup.GET("/:id", eventHandler.FindEventByID) + eventAPIGroup.POST("", eventHandler.CreateEvent) + eventAPIGroup.PUT("/:id", eventHandler.UpdateEvent) + eventAPIGroup.DELETE("/:id", eventHandler.DeleteEvent) + } + upgrader := wschat.NewDefaultUpgrader() wsServerV2 := wschat.NewWSServer(upgrader, authService, *mediaService) diff --git a/db/migrations/000022_event.down.sql b/db/migrations/000022_event.down.sql new file mode 100644 index 00000000..653c7400 --- /dev/null +++ b/db/migrations/000022_event.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS events; diff --git a/db/migrations/000022_event.up.sql b/db/migrations/000022_event.up.sql new file mode 100644 index 00000000..b3e8e71d --- /dev/null +++ b/db/migrations/000022_event.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY, + event_type VARCHAR NOT NULL, + author_id UUID NOT NULL, + name VARCHAR NOT NULL, + description TEXT NOT NULL, + media_id UUID, + topics TEXT[] NOT NULL, + max_participants INT, + fee INT NOT NULL, + start_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); diff --git a/internal/common/null.go b/internal/common/null.go index a44c17dd..1fd41883 100644 --- a/internal/common/null.go +++ b/internal/common/null.go @@ -42,6 +42,21 @@ func StrToNullStr(val string) sql.NullString { } } +func NullInt32ToIntPtr(val sql.NullInt32) *int { + if val.Valid { + intValue := int(val.Int32) + return &intValue + } + return nil +} + +func NullInt32ToInt32Ptr(val sql.NullInt32) *int32 { + if val.Valid { + return &val.Int32 + } + return nil +} + func NullInt64ToInt64Ptr(val sql.NullInt64) *int64 { if val.Valid { return &val.Int64 @@ -118,3 +133,17 @@ func NullTimeToStr(val sql.NullTime) string { } return "" } + +func NullTimeToTimePtr(val sql.NullTime) *time.Time { + if val.Valid { + return &val.Time + } + return nil +} + +func TimePtrToNullTime(val *time.Time) sql.NullTime { + return sql.NullTime{ + Time: DerefOrEmpty(val), + Valid: IsNotNil(val), + } +} diff --git a/internal/domain/event/model.go b/internal/domain/event/model.go new file mode 100644 index 00000000..1cef5ad4 --- /dev/null +++ b/internal/domain/event/model.go @@ -0,0 +1,25 @@ +package event + +import ( + "github.com/pet-sitter/pets-next-door-api/internal/domain/media" + "github.com/pet-sitter/pets-next-door-api/internal/domain/user" + databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" +) + +type Event struct { + Event databasegen.Event + Author user.WithoutPrivateInfo + Media *media.DetailView +} + +func ToEvent( + eventData databasegen.Event, + authorData user.WithoutPrivateInfo, + mediaData *media.DetailView, +) *Event { + return &Event{ + Event: eventData, + Author: authorData, + Media: mediaData, + } +} diff --git a/internal/domain/event/request.go b/internal/domain/event/request.go new file mode 100644 index 00000000..db513fb5 --- /dev/null +++ b/internal/domain/event/request.go @@ -0,0 +1,56 @@ +package event + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateRequest struct { + BaseCreateRequest + RecurringPeriod *EventRecurringPeriod `json:"recurringPeriod,omitempty"` +} + +type BaseCreateRequest struct { + EventType EventType `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + MediaID *uuid.UUID `json:"mediaId,omitempty"` + Topics []EventTopic `json:"topics"` + MaxParticipants *int `json:"maxParticipants,omitempty"` + Fee int `json:"fee"` + StartAt *time.Time `json:"startAt,omitempty"` +} + +type ShortTermCreateRequest struct { + BaseCreateRequest +} + +type RecurringCreateRequest struct { + BaseCreateRequest + RecurringPeriod EventRecurringPeriod `json:"recurringPeriod"` +} + +type UpdateRequest struct { + BaseUpdateRequest + RecurringPeriod *EventRecurringPeriod `json:"recurringPeriod,omitempty"` +} + +type BaseUpdateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + MediaID uuid.NullUUID `json:"mediaId,omitempty"` + Topics []EventTopic `json:"topics"` + MaxParticipants *int `json:"maxParticipants,omitempty"` + Fee int `json:"fee"` + StartAt *time.Time `json:"startAt,omitempty"` +} + +type ShortTermUpdateRequest struct { + BaseUpdateRequest +} + +type RecurringUpdateRequest struct { + BaseUpdateRequest + ReccuringPeriod EventRecurringPeriod `json:"recurringPeriod"` +} diff --git a/internal/domain/event/view.go b/internal/domain/event/view.go new file mode 100644 index 00000000..791a694c --- /dev/null +++ b/internal/domain/event/view.go @@ -0,0 +1,63 @@ +package event + +import ( + "time" + + "github.com/google/uuid" + utils "github.com/pet-sitter/pets-next-door-api/internal/common" + "github.com/pet-sitter/pets-next-door-api/internal/domain/media" + "github.com/pet-sitter/pets-next-door-api/internal/domain/user" +) + +type View struct { + ShortTermView + RecurringPeriod *EventRecurringPeriod `json:"recurringPeriod,omitempty"` +} + +type BaseView struct { + ID uuid.UUID `json:"id"` + EventType EventType `json:"type"` + Author user.WithoutPrivateInfo `json:"author"` + Name string `json:"name"` + Description string `json:"description"` + Media media.DetailView `json:"media"` + Topics []EventTopic `json:"topics"` + MaxParticipants *int `json:"maxParticipants,omitempty"` + Fee int `json:"fee"` + StartAt *time.Time `json:"startAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ShortTermView struct { + BaseView +} +type RecurringView struct { + BaseView + RecurringPeriod EventRecurringPeriod `json:"recurringPeriod"` +} + +func ToShortTermView(eventData *Event) ShortTermView { + topics := make([]EventTopic, len(eventData.Event.Topics)) + for i, topic := range eventData.Event.Topics { + topics[i] = EventTopic(topic) + } + view := ShortTermView{ + BaseView: BaseView{ + ID: eventData.Event.ID, + Author: eventData.Author, + EventType: EventType(eventData.Event.EventType), + Name: eventData.Event.Name, + Description: eventData.Event.Description, + Media: *eventData.Media, + Topics: topics, + MaxParticipants: utils.NullInt32ToIntPtr(eventData.Event.MaxParticipants), + Fee: int(eventData.Event.Fee), + StartAt: utils.NullTimeToTimePtr(eventData.Event.StartAt), + CreatedAt: eventData.Event.CreatedAt, + UpdatedAt: eventData.Event.UpdatedAt, + }, + } + + return view +} diff --git a/internal/domain/event/vo.go b/internal/domain/event/vo.go new file mode 100644 index 00000000..61365553 --- /dev/null +++ b/internal/domain/event/vo.go @@ -0,0 +1,58 @@ +package event + +type EventType string + +const ( + // 단기 + ShortTerm EventType = "SHORT_TERM" + // 정기 + Recurring EventType = "RECURRING" +) + +func (e EventType) String() string { + return string(e) +} + +func (e EventType) IsValid() bool { + switch e { + case ShortTerm, Recurring: + return true + } + return false +} + +type EventTopic string + +const ( + ETC EventTopic = "ETC" +) + +func (e EventTopic) String() string { + return string(e) +} + +func (e EventTopic) IsValid() bool { + // TODO: Add more topics + return e == ETC +} + +type EventRecurringPeriod string + +const ( + // 매일 + Daily EventRecurringPeriod = "DAILY" + // 매주 + Weekly EventRecurringPeriod = "WEEKLY" + // 2주에 한 번 + Biweekly EventRecurringPeriod = "BIWEEKLY" + // 매달 + Monthly EventRecurringPeriod = "MONTHLY" +) + +func (e EventRecurringPeriod) IsValid() bool { + switch e { + case Daily, Weekly, Biweekly, Monthly: + return true + } + return false +} diff --git a/internal/domain/user/view.go b/internal/domain/user/view.go index a6ef3e97..9ee72120 100644 --- a/internal/domain/user/view.go +++ b/internal/domain/user/view.go @@ -117,3 +117,18 @@ func ToListWithoutPrivateInfo( ul.CalcLastPage() return ul } + +func ToListWithoutPrivateInfoFromFindByIDs( + rows []databasegen.FindUsersByIDsRow, +) []WithoutPrivateInfo { + var items []WithoutPrivateInfo + for _, row := range rows { + items = append(items, WithoutPrivateInfo{ + ID: row.ID, + Nickname: row.Nickname, + ProfileImageURL: utils.NullStrToStrPtr(row.ProfileImageUrl), + }) + } + + return items +} diff --git a/internal/infra/database/database.go b/internal/infra/database/database.go index 0f895760..3166934a 100644 --- a/internal/infra/database/database.go +++ b/internal/infra/database/database.go @@ -44,6 +44,7 @@ func (db *DB) Flush() error { tableNames := []string{ "users", "breeds", + "events", "resource_media", "sos_posts_pets", "media", diff --git a/internal/infra/database/gen/breeds.sql.go b/internal/infra/database/gen/breeds.sql.go index de0861df..5e3a881c 100644 --- a/internal/infra/database/gen/breeds.sql.go +++ b/internal/infra/database/gen/breeds.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: breeds.sql package databasegen diff --git a/internal/infra/database/gen/chats.sql.go b/internal/infra/database/gen/chats.sql.go index fde39e74..5e72d969 100644 --- a/internal/infra/database/gen/chats.sql.go +++ b/internal/infra/database/gen/chats.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: chats.sql package databasegen diff --git a/internal/infra/database/gen/db.go b/internal/infra/database/gen/db.go index c298514d..b635c22f 100644 --- a/internal/infra/database/gen/db.go +++ b/internal/infra/database/gen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 package databasegen diff --git a/internal/infra/database/gen/events.sql.go b/internal/infra/database/gen/events.sql.go new file mode 100644 index 00000000..19ee9b49 --- /dev/null +++ b/internal/infra/database/gen/events.sql.go @@ -0,0 +1,276 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: events.sql + +package databasegen + +import ( + "context" + "database/sql" + + "github.com/google/uuid" + "github.com/lib/pq" +) + +const createEvent = `-- name: CreateEvent :one +INSERT INTO + events ( + id, + event_type, + author_id, + name, + description, + media_id, + topics, + max_participants, + fee, + start_at, + created_at, + updated_at + ) +VALUES + ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + NOW(), + NOW() + ) +RETURNING + id, event_type, author_id, name, description, media_id, topics, max_participants, fee, start_at, created_at, updated_at, deleted_at +` + +type CreateEventParams struct { + ID uuid.UUID + EventType string + AuthorID uuid.UUID + Name string + Description string + MediaID uuid.NullUUID + Topics []string + MaxParticipants sql.NullInt32 + Fee int32 + StartAt sql.NullTime +} + +func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { + row := q.db.QueryRowContext(ctx, createEvent, + arg.ID, + arg.EventType, + arg.AuthorID, + arg.Name, + arg.Description, + arg.MediaID, + pq.Array(arg.Topics), + arg.MaxParticipants, + arg.Fee, + arg.StartAt, + ) + var i Event + err := row.Scan( + &i.ID, + &i.EventType, + &i.AuthorID, + &i.Name, + &i.Description, + &i.MediaID, + pq.Array(&i.Topics), + &i.MaxParticipants, + &i.Fee, + &i.StartAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const deleteEvent = `-- name: DeleteEvent :exec +UPDATE events +SET + deleted_at = NOW() +WHERE + id = $1 +` + +func (q *Queries) DeleteEvent(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteEvent, id) + return err +} + +const findEvent = `-- name: FindEvent :one +SELECT + events.id, events.event_type, events.author_id, events.name, events.description, events.media_id, events.topics, events.max_participants, events.fee, events.start_at, events.created_at, events.updated_at, events.deleted_at +FROM + events +WHERE + ( + events.deleted_at IS NULL + OR $1::boolean = TRUE + ) + AND (events.id = $2 OR $2 IS NULL) +LIMIT + 1 +` + +type FindEventParams struct { + IncludeDeleted bool + ID uuid.NullUUID +} + +func (q *Queries) FindEvent(ctx context.Context, arg FindEventParams) (Event, error) { + row := q.db.QueryRowContext(ctx, findEvent, arg.IncludeDeleted, arg.ID) + var i Event + err := row.Scan( + &i.ID, + &i.EventType, + &i.AuthorID, + &i.Name, + &i.Description, + &i.MediaID, + pq.Array(&i.Topics), + &i.MaxParticipants, + &i.Fee, + &i.StartAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const findEvents = `-- name: FindEvents :many +SELECT + events.id, events.event_type, events.author_id, events.name, events.description, events.media_id, events.topics, events.max_participants, events.fee, events.start_at, events.created_at, events.updated_at, events.deleted_at +FROM + events +WHERE + ( + events.deleted_at IS NULL + OR $2::boolean = TRUE + ) + AND (id > $3::uuid OR $3 IS NULL) + AND (id < $4::uuid OR $4 IS NULL) + AND (events.author_id = $5 OR $5 IS NULL) +ORDER BY + events.created_at DESC +LIMIT + $1 +` + +type FindEventsParams struct { + Limit int32 + IncludeDeleted bool + Prev uuid.NullUUID + Next uuid.NullUUID + AuthorID uuid.NullUUID +} + +func (q *Queries) FindEvents(ctx context.Context, arg FindEventsParams) ([]Event, error) { + rows, err := q.db.QueryContext(ctx, findEvents, + arg.Limit, + arg.IncludeDeleted, + arg.Prev, + arg.Next, + arg.AuthorID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Event + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.EventType, + &i.AuthorID, + &i.Name, + &i.Description, + &i.MediaID, + pq.Array(&i.Topics), + &i.MaxParticipants, + &i.Fee, + &i.StartAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateEvent = `-- name: UpdateEvent :one +UPDATE events +SET + name = $1, + description = $2, + media_id = $3, + topics = $4, + max_participants = $5, + fee = $6, + start_at = $7, + updated_at = NOW() +WHERE + id = $8 +RETURNING + id, event_type, author_id, name, description, media_id, topics, max_participants, fee, start_at, created_at, updated_at, deleted_at +` + +type UpdateEventParams struct { + Name string + Description string + MediaID uuid.NullUUID + Topics []string + MaxParticipants sql.NullInt32 + Fee int32 + StartAt sql.NullTime + ID uuid.UUID +} + +func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) (Event, error) { + row := q.db.QueryRowContext(ctx, updateEvent, + arg.Name, + arg.Description, + arg.MediaID, + pq.Array(arg.Topics), + arg.MaxParticipants, + arg.Fee, + arg.StartAt, + arg.ID, + ) + var i Event + err := row.Scan( + &i.ID, + &i.EventType, + &i.AuthorID, + &i.Name, + &i.Description, + &i.MediaID, + pq.Array(&i.Topics), + &i.MaxParticipants, + &i.Fee, + &i.StartAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/internal/infra/database/gen/media.sql.go b/internal/infra/database/gen/media.sql.go index 2e3e620e..f7640e90 100644 --- a/internal/infra/database/gen/media.sql.go +++ b/internal/infra/database/gen/media.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: media.sql package databasegen diff --git a/internal/infra/database/gen/models.go b/internal/infra/database/gen/models.go index a922a213..15081448 100644 --- a/internal/infra/database/gen/models.go +++ b/internal/infra/database/gen/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 package databasegen @@ -41,6 +41,22 @@ type ChatRoom struct { ID uuid.UUID } +type Event struct { + ID uuid.UUID + EventType string + AuthorID uuid.UUID + Name string + Description string + MediaID uuid.NullUUID + Topics []string + MaxParticipants sql.NullInt32 + Fee int32 + StartAt sql.NullTime + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt sql.NullTime +} + type Medium struct { MediaType string Url string diff --git a/internal/infra/database/gen/pets.sql.go b/internal/infra/database/gen/pets.sql.go index e332e4e4..20344add 100644 --- a/internal/infra/database/gen/pets.sql.go +++ b/internal/infra/database/gen/pets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: pets.sql package databasegen diff --git a/internal/infra/database/gen/resource_media.sql.go b/internal/infra/database/gen/resource_media.sql.go index 920df515..1792c230 100644 --- a/internal/infra/database/gen/resource_media.sql.go +++ b/internal/infra/database/gen/resource_media.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: resource_media.sql package databasegen diff --git a/internal/infra/database/gen/sos_conditions.sql.go b/internal/infra/database/gen/sos_conditions.sql.go index 2b16ca1a..3c4c048a 100644 --- a/internal/infra/database/gen/sos_conditions.sql.go +++ b/internal/infra/database/gen/sos_conditions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: sos_conditions.sql package databasegen diff --git a/internal/infra/database/gen/sos_posts.sql.go b/internal/infra/database/gen/sos_posts.sql.go index 44d0e145..3361e0aa 100644 --- a/internal/infra/database/gen/sos_posts.sql.go +++ b/internal/infra/database/gen/sos_posts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: sos_posts.sql package databasegen diff --git a/internal/infra/database/gen/users.sql.go b/internal/infra/database/gen/users.sql.go index c9dcc3a9..25694913 100644 --- a/internal/infra/database/gen/users.sql.go +++ b/internal/infra/database/gen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.27.0 // source: users.sql package databasegen @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" ) const createUser = `-- name: CreateUser :one @@ -243,6 +244,54 @@ func (q *Queries) FindUsers(ctx context.Context, arg FindUsersParams) ([]FindUse return items, nil } +const findUsersByIDs = `-- name: FindUsersByIDs :many +SELECT users.id, + users.nickname, + media.url AS profile_image_url +FROM users + LEFT OUTER JOIN + media + ON + users.profile_image_id = media.id +WHERE (users.id = ANY ($1::uuid[])) + AND (users.deleted_at IS NULL OR $2::boolean = TRUE) +ORDER BY users.created_at DESC +` + +type FindUsersByIDsParams struct { + Ids []uuid.UUID + IncludeDeleted bool +} + +type FindUsersByIDsRow struct { + ID uuid.UUID + Nickname string + ProfileImageUrl sql.NullString +} + +func (q *Queries) FindUsersByIDs(ctx context.Context, arg FindUsersByIDsParams) ([]FindUsersByIDsRow, error) { + rows, err := q.db.QueryContext(ctx, findUsersByIDs, pq.Array(arg.Ids), arg.IncludeDeleted) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindUsersByIDsRow + for rows.Next() { + var i FindUsersByIDsRow + if err := rows.Scan(&i.ID, &i.Nickname, &i.ProfileImageUrl); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateUserByFbUID = `-- name: UpdateUserByFbUID :one UPDATE users diff --git a/internal/service/event_service.go b/internal/service/event_service.go new file mode 100644 index 00000000..94e5953b --- /dev/null +++ b/internal/service/event_service.go @@ -0,0 +1,184 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + pnd "github.com/pet-sitter/pets-next-door-api/api" + utils "github.com/pet-sitter/pets-next-door-api/internal/common" + "github.com/pet-sitter/pets-next-door-api/internal/domain/event" + "github.com/pet-sitter/pets-next-door-api/internal/domain/media" + "github.com/pet-sitter/pets-next-door-api/internal/domain/user" + "github.com/pet-sitter/pets-next-door-api/internal/infra/database" + databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" +) + +type EventService struct { + conn *database.DB + mediaService *MediaService + userService *UserService +} + +func NewEventService( + conn *database.DB, + userService *UserService, + mediaService *MediaService, +) *EventService { + return &EventService{ + conn: conn, + userService: userService, + mediaService: mediaService, + } +} + +func (s *EventService) CreateEvent( + ctx context.Context, + authorID uuid.UUID, + req event.CreateRequest, +) (*event.Event, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, pnd.ErrUnknown(errors.New("failed to generate id")) + } + + q := databasegen.New(s.conn) + + // Check if author exists + authorData, err := s.userService.FindUserProfile( + ctx, + user.FindUserParams{ID: uuid.NullUUID{UUID: authorID, Valid: true}}, + ) + if err != nil { + return nil, err + } + authorView := user.WithoutPrivateInfo{ + ID: authorData.ID, + Nickname: authorData.Nickname, + ProfileImageURL: authorData.ProfileImageURL, + } + + // Check if media exists + if req.MediaID != nil { + if _, err = s.mediaService.FindMediaByID(ctx, *req.MediaID); err != nil { + return nil, err + } + } + mediaID := uuid.NullUUID{} + if req.MediaID != nil { + mediaID = uuid.NullUUID{UUID: *req.MediaID, Valid: true} + } + mediaData, err := s.mediaService.FindMediaByID(ctx, mediaID.UUID) + if err != nil { + return nil, err + } + + // Map topic[] to string[] + topics := make([]string, len(req.Topics)) + for i, topic := range req.Topics { + topics[i] = topic.String() + } + + eventData, err := q.CreateEvent(ctx, databasegen.CreateEventParams{ + ID: id, + AuthorID: authorID, + EventType: req.EventType.String(), + Name: req.Name, + Description: req.Description, + MediaID: mediaID, + Topics: topics, + MaxParticipants: utils.IntPtrToNullInt32(req.MaxParticipants), + Fee: int32(req.Fee), + StartAt: utils.TimePtrToNullTime(req.StartAt), + }) + if err != nil { + return nil, err + } + + return event.ToEvent(eventData, authorView, mediaData), nil +} + +func (s *EventService) FindEvent( + ctx context.Context, + params databasegen.FindEventParams, +) (*event.Event, error) { + q := databasegen.New(s.conn) + + eventData, err := q.FindEvent(ctx, params) + if err != nil { + return nil, err + } + + authorData, err := s.userService.FindUserProfile( + ctx, + user.FindUserParams{ID: uuid.NullUUID{UUID: eventData.AuthorID, Valid: true}}, + ) + if err != nil { + return nil, err + } + authorView := user.WithoutPrivateInfo{ + ID: authorData.ID, + Nickname: authorData.Nickname, + ProfileImageURL: authorData.ProfileImageURL, + } + + var mediaData *media.DetailView + if eventData.MediaID.Valid { + mediaData, err = s.mediaService.FindMediaByID(ctx, eventData.MediaID.UUID) + if err != nil { + return nil, err + } + } + + return event.ToEvent(eventData, authorView, mediaData), nil +} + +func (s *EventService) FindEvents( + ctx context.Context, + params databasegen.FindEventsParams, +) ([]*event.Event, error) { + q := databasegen.New(s.conn) + + eventsData, err := q.FindEvents(ctx, params) + if err != nil { + return nil, err + } + + authorIDs := make([]uuid.UUID, len(eventsData)) + for i, eventData := range eventsData { + authorIDs[i] = eventData.AuthorID + } + authorsData, err := s.userService.FindUsersByIDs(ctx, databasegen.FindUsersByIDsParams{ + Ids: authorIDs, + }) + if err != nil { + return nil, err + } + authorIDsMap := make(map[uuid.UUID]user.WithoutPrivateInfo) + for _, authorData := range authorsData { + authorIDsMap[authorData.ID] = authorData + } + + mediaIDs := make([]uuid.UUID, len(eventsData)) + for i, eventData := range eventsData { + if eventData.MediaID.Valid { + mediaIDs[i] = eventData.MediaID.UUID + } + } + mediasData, err := s.mediaService.FindMediasByIDs(ctx, mediaIDs) + if err != nil { + return nil, err + } + mediasDataMap := make(map[uuid.UUID]media.DetailView) + for _, mediaData := range mediasData { + mediasDataMap[mediaData.ID] = mediaData + } + + events := make([]*event.Event, len(eventsData)) + for i, eventData := range eventsData { + authorData := authorIDsMap[eventData.AuthorID] + mediaData := mediasDataMap[eventData.MediaID.UUID] + events[i] = event.ToEvent(eventData, authorData, &mediaData) + } + return events, nil +} diff --git a/internal/service/sos_post_service.go b/internal/service/sos_post_service.go index 9a64b052..abfa7646 100644 --- a/internal/service/sos_post_service.go +++ b/internal/service/sos_post_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "log" "time" "github.com/google/uuid" @@ -464,8 +463,6 @@ func (service *SOSPostService) SaveLinkSOSPostImage( ctx context.Context, tx *databasegen.Queries, imageIDs []uuid.UUID, sosPostID uuid.UUID, ) error { for _, mediaID := range imageIDs { - log.Default().Println("mediaID", mediaID) - if err := tx.LinkResourceMedia(ctx, databasegen.LinkResourceMediaParams{ ID: datatype.NewUUIDV7(), MediaID: mediaID, @@ -482,8 +479,6 @@ func (service *SOSPostService) SaveLinkConditions( ctx context.Context, tx *databasegen.Queries, conditionIDs []uuid.UUID, sosPostID uuid.UUID, ) error { for _, conditionID := range conditionIDs { - log.Default().Println("conditionID", conditionID) - if err := tx.LinkSOSPostCondition(ctx, databasegen.LinkSOSPostConditionParams{ ID: datatype.NewUUIDV7(), SosPostID: sosPostID, @@ -499,8 +494,6 @@ func (service *SOSPostService) SaveLinkPets( ctx context.Context, tx *databasegen.Queries, petIDs []uuid.UUID, sosPostID uuid.UUID, ) error { for _, petID := range petIDs { - log.Default().Println("petID", petID) - if err := tx.LinkSOSPostPet(ctx, databasegen.LinkSOSPostPetParams{ ID: datatype.NewUUIDV7(), SosPostID: sosPostID, diff --git a/internal/service/tests/event_service_test.go b/internal/service/tests/event_service_test.go new file mode 100644 index 00000000..65f18d5d --- /dev/null +++ b/internal/service/tests/event_service_test.go @@ -0,0 +1,173 @@ +package service_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "gopkg.in/go-playground/assert.v1" + + "github.com/pet-sitter/pets-next-door-api/internal/domain/event" + databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" + + "github.com/pet-sitter/pets-next-door-api/internal/domain/media" + + "github.com/pet-sitter/pets-next-door-api/internal/tests" +) + +func TestCreateEvent(t *testing.T) { + t.Run("이벤트를 새로 생성한다", func(t *testing.T) { + db, tearDown := tests.SetUp(t) + defer tearDown(t) + ctx := context.Background() + mediaService := tests.NewMockMediaService(db) + userService := tests.NewMockUserService(db) + eventService := tests.NewMockEventService(db) + + // Given + profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") + author, _ := userService.RegisterUser( + ctx, + tests.NewDummyRegisterUserRequest(uuid.NullUUID{UUID: profileImage.ID, Valid: true}), + ) + eventMedia, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "event_thumbnail.jpg") + + // When + now := time.Now().UTC() + maxParticipants := 10 + created, _ := eventService.CreateEvent(ctx, author.ID, event.CreateRequest{ + BaseCreateRequest: event.BaseCreateRequest{ + EventType: event.ShortTerm, + Name: "테스트 이벤트", + Description: "테스트 이벤트 설명", + Topics: []event.EventTopic{event.ETC}, + MediaID: &eventMedia.ID, + MaxParticipants: &maxParticipants, + Fee: 3000, + StartAt: &now, + }, + }) + + // Then + found, err := eventService.FindEvent( + ctx, + databasegen.FindEventParams{ID: uuid.NullUUID{UUID: created.Event.ID, Valid: true}}, + ) + assert.Equal(t, err, nil) + assertEventEquals(t, created, found) + }) +} + +func TestFindEvents(t *testing.T) { + t.Run("이벤트 목록을 조회한다", func(t *testing.T) { + db, tearDown := tests.SetUp(t) + defer tearDown(t) + ctx := context.Background() + mediaService := tests.NewMockMediaService(db) + userService := tests.NewMockUserService(db) + eventService := tests.NewMockEventService(db) + + // Given + profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") + owner, _ := userService.RegisterUser( + ctx, + tests.NewDummyRegisterUserRequest(uuid.NullUUID{UUID: profileImage.ID, Valid: true}), + ) + eventMedia, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "event_thumbnail.jpg") + + // When + now := time.Now().UTC() + maxParticipants := 10 + created, _ := eventService.CreateEvent(ctx, owner.ID, event.CreateRequest{ + BaseCreateRequest: event.BaseCreateRequest{ + EventType: event.ShortTerm, + Name: "테스트 이벤트", + Description: "테스트 이벤트 설명", + Topics: []event.EventTopic{event.ETC}, + MediaID: &eventMedia.ID, + MaxParticipants: &maxParticipants, + Fee: 3000, + StartAt: &now, + }, + }) + + // Then + found, err := eventService.FindEvents( + ctx, + databasegen.FindEventsParams{ + Limit: 10, + AuthorID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + }, + ) + assert.Equal(t, err, nil) + assert.Equal(t, 1, len(found)) + assertEventEquals(t, created, found[0]) + }) + + t.Run("작성자 ID로 이벤트 목록을 조회할 수 있다", func(t *testing.T) { + db, tearDown := tests.SetUp(t) + defer tearDown(t) + ctx := context.Background() + mediaService := tests.NewMockMediaService(db) + userService := tests.NewMockUserService(db) + eventService := tests.NewMockEventService(db) + + // Given + profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") + author, _ := userService.RegisterUser( + ctx, + tests.NewDummyRegisterUserRequest(uuid.NullUUID{UUID: profileImage.ID, Valid: true}), + ) + eventMedia, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "event_thumbnail.jpg") + + // When + now := time.Now().UTC() + maxParticipants := 10 + created, _ := eventService.CreateEvent(ctx, author.ID, event.CreateRequest{ + BaseCreateRequest: event.BaseCreateRequest{ + EventType: event.ShortTerm, + Name: "테스트 이벤트", + Description: "테스트 이벤트 설명", + Topics: []event.EventTopic{event.ETC}, + MediaID: &eventMedia.ID, + MaxParticipants: &maxParticipants, + Fee: 3000, + StartAt: &now, + }, + }) + + // Then + found, err := eventService.FindEvents( + ctx, + databasegen.FindEventsParams{ + Limit: 10, + AuthorID: uuid.NullUUID{UUID: author.ID, Valid: true}, + }, + ) + assert.Equal(t, err, nil) + assert.Equal(t, 1, len(found)) + assertEventEquals(t, created, found[0]) + }) +} + +func assertEventEquals( + t *testing.T, + expected *event.Event, + actual *event.Event, +) { + t.Helper() + assert.Equal(t, expected.Event.ID, actual.Event.ID) + assert.Equal(t, expected.Event.AuthorID, actual.Event.AuthorID) + assert.Equal(t, expected.Event.EventType, actual.Event.EventType) + assert.Equal(t, expected.Event.Name, actual.Event.Name) + assert.Equal(t, expected.Event.Description, actual.Event.Description) + assert.Equal(t, expected.Event.MediaID, actual.Event.MediaID) + assert.Equal(t, expected.Event.Topics, actual.Event.Topics) + assert.Equal(t, expected.Event.MaxParticipants, actual.Event.MaxParticipants) + assert.Equal(t, expected.Event.Fee, actual.Event.Fee) + assert.Equal(t, expected.Event.StartAt, actual.Event.StartAt) + assert.Equal(t, expected.Event.CreatedAt, actual.Event.CreatedAt) + assert.Equal(t, expected.Event.UpdatedAt, actual.Event.UpdatedAt) + assert.Equal(t, expected.Event.DeletedAt, actual.Event.DeletedAt) +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 618576b4..565249ec 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -76,6 +76,17 @@ func (service *UserService) FindUsers( return user.ToListWithoutPrivateInfo(params.Page, params.Size, rows), nil } +func (service *UserService) FindUsersByIDs( + ctx context.Context, params databasegen.FindUsersByIDsParams, +) ([]user.WithoutPrivateInfo, error) { + rows, err := databasegen.New(service.conn).FindUsersByIDs(ctx, params) + if err != nil { + return nil, err + } + + return user.ToListWithoutPrivateInfoFromFindByIDs(rows), nil +} + func (service *UserService) FindUser( ctx context.Context, params user.FindUserParams, diff --git a/internal/tests/service.go b/internal/tests/service.go index 6e9dca89..2c1ecb45 100644 --- a/internal/tests/service.go +++ b/internal/tests/service.go @@ -46,6 +46,10 @@ func NewMockSOSConditionService(db *database.DB) *service.SOSConditionService { return service.NewSOSConditionService(db) } +func NewMockEventService(db *database.DB) *service.EventService { + return service.NewEventService(db, NewMockUserService(db), NewMockMediaService(db)) +} + func NewMockChatService(db *database.DB) *service.ChatService { return service.NewChatService(db) } diff --git a/queries/events.sql b/queries/events.sql new file mode 100755 index 00000000..02c6a704 --- /dev/null +++ b/queries/events.sql @@ -0,0 +1,88 @@ +-- name: CreateEvent :one +INSERT INTO + events ( + id, + event_type, + author_id, + name, + description, + media_id, + topics, + max_participants, + fee, + start_at, + created_at, + updated_at + ) +VALUES + ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + NOW(), + NOW() + ) +RETURNING + *; + +-- name: FindEvents :many +SELECT + events.* +FROM + events +WHERE + ( + events.deleted_at IS NULL + OR sqlc.arg ('include_deleted')::boolean = TRUE + ) + AND (id > sqlc.narg ('prev')::uuid OR sqlc.narg ('prev') IS NULL) + AND (id < sqlc.narg ('next')::uuid OR sqlc.narg ('next') IS NULL) + AND (events.author_id = sqlc.narg ('author_id') OR sqlc.narg ('author_id') IS NULL) +ORDER BY + events.created_at DESC +LIMIT + $1; + +-- name: FindEvent :one +SELECT + events.* +FROM + events +WHERE + ( + events.deleted_at IS NULL + OR sqlc.arg ('include_deleted')::boolean = TRUE + ) + AND (events.id = sqlc.narg('id') OR sqlc.narg('id') IS NULL) +LIMIT + 1; + +-- name: UpdateEvent :one +UPDATE events +SET + name = $1, + description = $2, + media_id = $3, + topics = $4, + max_participants = $5, + fee = $6, + start_at = $7, + updated_at = NOW() +WHERE + id = $8 +RETURNING + *; + +-- name: DeleteEvent :exec +UPDATE events +SET + deleted_at = NOW() +WHERE + id = $1; diff --git a/queries/users.sql b/queries/users.sql index f09cc52d..b8f57ec1 100644 --- a/queries/users.sql +++ b/queries/users.sql @@ -30,6 +30,19 @@ WHERE (users.id = sqlc.narg('id') OR sqlc.narg('id') IS NULL) ORDER BY users.created_at DESC LIMIT $1 OFFSET $2; +-- name: FindUsersByIDs :many +SELECT users.id, + users.nickname, + media.url AS profile_image_url +FROM users + LEFT OUTER JOIN + media + ON + users.profile_image_id = media.id +WHERE (users.id = ANY (sqlc.arg('ids')::uuid[])) + AND (users.deleted_at IS NULL OR sqlc.arg('include_deleted')::boolean = TRUE) +ORDER BY users.created_at DESC; + -- name: FindUser :one SELECT users.id, users.email,