From 1c6c8d4a1554b671663391caf99ad47a9b68047f Mon Sep 17 00:00:00 2001 From: Jo YoHan Date: Wed, 29 May 2024 10:43:56 +0900 Subject: [PATCH] feat: add POST Apply subscription API --- README.md | 16 ++ cmd/server/wire_gen.go | 6 +- go.mod | 1 + go.sum | 2 + infra/config/config.go | 13 ++ infra/config/config.json | 2 +- .../database/repository/subscription_repo.go | 72 +++++++ infra/infra.go | 1 + infra/router/handler/item_handler.go | 4 +- infra/router/handler/subscription_handler.go | 59 ++++++ infra/router/router.go | 14 +- internal/controller/controller.go | 2 +- .../{item_find_all_dto.go => find_all_dto.go} | 0 .../rest/dto/subscription_dto/apply_dto.go | 34 +++ .../rest/dto/subscription_dto/approve_dto.go | 9 + .../rest/subscription_controller.go | 48 +++++ internal/entity/feed.go | 10 +- internal/entity/subscription.go | 21 ++ .../usecase/subscription/subscriber_ucase.go | 199 ++++++++++++++++++ .../subscription/template/subscription.html | 45 ++++ internal/usecase/usecase.go | 3 +- {docs => third_party/docs}/docs.go | 132 +++++++++++- {docs => third_party/docs}/swagger.json | 132 +++++++++++- {docs => third_party/docs}/swagger.yaml | 91 +++++++- 24 files changed, 898 insertions(+), 18 deletions(-) create mode 100644 README.md create mode 100644 infra/database/repository/subscription_repo.go create mode 100644 infra/router/handler/subscription_handler.go rename internal/controller/rest/dto/item_dto/{item_find_all_dto.go => find_all_dto.go} (100%) create mode 100644 internal/controller/rest/dto/subscription_dto/apply_dto.go create mode 100644 internal/controller/rest/dto/subscription_dto/approve_dto.go create mode 100644 internal/controller/rest/subscription_controller.go create mode 100644 internal/entity/subscription.go create mode 100644 internal/usecase/subscription/subscriber_ucase.go create mode 100644 internal/usecase/subscription/template/subscription.html rename {docs => third_party/docs}/docs.go (70%) rename {docs => third_party/docs}/swagger.json (68%) rename {docs => third_party/docs}/swagger.yaml (66%) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8c24f3 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +```bash +go run ./cmd/server . + +# "google/wire" module +go install github.com/google/wire/cmd/wire@latest +wire ./cmd/server + +# "swaggo/swag" module +go install github.com/swaggo/swag/cmd/swag@latest +swag init -pd -g ./cmd/server/main.go -o ./third_party/docs/ + +# "golang.org/x/tools" module +go install golang.org/x/tools/cmd/goimports@latest +goimports -w . +gofmt -s -w . +``` diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 25cab25..218ed90 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -13,6 +13,7 @@ import ( "github.com/team-nerd-planet/api-server/infra/router" "github.com/team-nerd-planet/api-server/internal/controller/rest" "github.com/team-nerd-planet/api-server/internal/usecase/item" + "github.com/team-nerd-planet/api-server/internal/usecase/subscription" "github.com/team-nerd-planet/api-server/internal/usecase/tag" ) @@ -35,6 +36,9 @@ func InitServer() (router.Router, error) { skillTagRepo := repository.NewSkillTagRepo(databaseDatabase) skillTagUsecase := tag.NewSkillTagUsecase(skillTagRepo) tagController := rest.NewTagController(jobTagUsecase, skillTagUsecase) - routerRouter := router.NewRouter(configConfig, itemController, tagController) + subscriptionRepo := repository.NewSubscriptionRepo(databaseDatabase) + subscriptionUsecase := subscription.NewSubscriptionUsecase(subscriptionRepo, configConfig) + subscriptionController := rest.NewSubscriptionController(subscriptionUsecase) + routerRouter := router.NewRouter(configConfig, itemController, tagController, subscriptionController) return routerRouter, nil } diff --git a/go.mod b/go.mod index 5d9fa98..107472d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-contrib/cache v1.3.0 github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/wire v0.6.0 github.com/lib/pq v1.10.9 github.com/spf13/viper v1.18.2 diff --git a/go.sum b/go.sum index 0d20018..cfc0f5c 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= diff --git a/infra/config/config.go b/infra/config/config.go index 7eaa782..c571847 100644 --- a/infra/config/config.go +++ b/infra/config/config.go @@ -13,6 +13,8 @@ type Config struct { Rest Rest `mapstructure:"REST"` Database Database `mapstructure:"DATABASE"` Swagger Swagger `mapstructure:"SWAGGER"` + Jwt Jwt `mapstructure:"JWT"` + Smtp Smtp `mapstructure:"SMTP"` } type App struct { @@ -38,6 +40,17 @@ type Swagger struct { BasePath string `mapstructure:"BASE_PATH"` } +type Jwt struct { + SecretKey string `mapstructure:"SECRET_KEY"` +} + +type Smtp struct { + Host string `mapstructure:"HOST"` + Port int `mapstructure:"PORT"` + UserName string `mapstructure:"USER_NAME"` + Password string `mapstructure:"PASSWORD"` +} + func NewConfig() (*Config, error) { _, b, _, _ := runtime.Caller(0) configDirPath := path.Join(path.Dir(b)) diff --git a/infra/config/config.json b/infra/config/config.json index 481edd6..c598b84 100644 --- a/infra/config/config.json +++ b/infra/config/config.json @@ -15,7 +15,7 @@ "LOG_LEVEL": 4 }, "SWAGGER": { - "HOST": "localhost", + "HOST": "localhost:5000", "BASE_PATH": "/" } } diff --git a/infra/database/repository/subscription_repo.go b/infra/database/repository/subscription_repo.go new file mode 100644 index 0000000..e869370 --- /dev/null +++ b/infra/database/repository/subscription_repo.go @@ -0,0 +1,72 @@ +package repository + +import ( + "errors" + + "github.com/team-nerd-planet/api-server/infra/database" + "github.com/team-nerd-planet/api-server/internal/entity" + "gorm.io/gorm" +) + +type SubscriptionRepo struct { + db *database.Database +} + +func NewSubscriptionRepo(db *database.Database) entity.SubscriptionRepo { + db.AutoMigrate(&entity.Subscription{}) + + return &SubscriptionRepo{ + db: db, + } +} + +// Create implements entity.SubscriptionRepo. +func (sr *SubscriptionRepo) Create(newSubscription entity.Subscription) (*entity.Subscription, error) { + err := sr.db.Create(&newSubscription).Error + if err != nil { + return nil, err + } + + return &newSubscription, nil +} + +// Delete implements entity.SubscriptionRepo. +func (sr *SubscriptionRepo) Delete(id int64) (*entity.Subscription, error) { + panic("unimplemented") +} + +// ExistEmail implements entity.SubscriptionRepo. +func (sr *SubscriptionRepo) ExistEmail(email string) (*int64, error) { + data := entity.Subscription{} + + err := sr.db.Select("id").Take(&data, "email = ?", email).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } else if err != nil { + return nil, err + } + + id := int64(data.ID) + return &id, nil +} + +// Update implements entity.SubscriptionRepo. +func (sr *SubscriptionRepo) Update(id int64, newSubscription entity.Subscription) (*entity.Subscription, error) { + var ( + subscription entity.Subscription + ) + + err := sr.db.First(&subscription, id).Error + if err != nil { + return nil, err + } + + subscription = newSubscription + subscription.ID = uint(id) + err = sr.db.Save(&subscription).Error + if err != nil { + return nil, err + } + + return &subscription, nil +} diff --git a/infra/infra.go b/infra/infra.go index 56774c3..0254b2d 100644 --- a/infra/infra.go +++ b/infra/infra.go @@ -15,4 +15,5 @@ var InfraSet = wire.NewSet( repository.NewItemRepo, repository.NewJobTagRepo, repository.NewSkillTagRepo, + repository.NewSubscriptionRepo, ) diff --git a/infra/router/handler/item_handler.go b/infra/router/handler/item_handler.go index 8f483ed..95d8b91 100644 --- a/infra/router/handler/item_handler.go +++ b/infra/router/handler/item_handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/team-nerd-planet/api-server/infra/router/util" "github.com/team-nerd-planet/api-server/internal/controller/rest" + _ "github.com/team-nerd-planet/api-server/internal/controller/rest/dto" "github.com/team-nerd-planet/api-server/internal/controller/rest/dto/item_dto" _ "github.com/team-nerd-planet/api-server/internal/entity" ) @@ -23,7 +24,7 @@ import ( // @Param job_tags query []int64 false "관련 직무 DB ID 배열" collectionFormat(multi) // @Param skill_tags query []int64 false "관련 스킬 DB ID 배열" collectionFormat(multi) // @Param page query int true "페이지" -// @Success 200 {object} item_dto.FindAllItemRes +// @Success 200 {object} dto.Paginated[[]item_dto.FindAllItemRes] // @Failure 400 {object} util.HTTPError // @Failure 404 {object} util.HTTPError // @Failure 500 {object} util.HTTPError @@ -31,7 +32,6 @@ import ( func ListItems(c *gin.Context, ctrl rest.ItemController) { req, err := util.ValidateQuery[item_dto.FindAllItemReq](c) if err != nil { - c.Status(http.StatusBadRequest) util.NewError(c, http.StatusBadRequest, err) return } diff --git a/infra/router/handler/subscription_handler.go b/infra/router/handler/subscription_handler.go new file mode 100644 index 0000000..84d8c0b --- /dev/null +++ b/infra/router/handler/subscription_handler.go @@ -0,0 +1,59 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/team-nerd-planet/api-server/infra/router/util" + "github.com/team-nerd-planet/api-server/internal/controller/rest" + "github.com/team-nerd-planet/api-server/internal/controller/rest/dto/subscription_dto" +) + +// Apply +// +// @Summary Apply subscription +// @Description apply for subscription +// @Tags subscription +// @Schemes http +// @Accept json +// @Produce json +// @Param request body subscription_dto.ApplyReq true "contents for applying for subscription." +// @Success 200 {object} subscription_dto.ApplyRes +// @Failure 400 {object} util.HTTPError +// @Failure 500 {object} util.HTTPError +// @Router /v1/subscription/apply [post] +func Apply(c *gin.Context, ctrl rest.SubscriptionController) { + req, err := util.ValidateBody[subscription_dto.ApplyReq](c) + if err != nil { + util.NewError(c, http.StatusBadRequest, err) + return + } + + res, ok := ctrl.Apply(*req) + if !ok { + util.NewError(c, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, res) +} + +func Approve(c *gin.Context, ctrl rest.SubscriptionController) { + req, err := util.ValidateQuery[subscription_dto.ApproveReq](c) + if err != nil { + c.Redirect(http.StatusBadRequest, "https://www.nerdplanet.app") + return + } + + res, ok := ctrl.Approve(*req) + if !ok { + c.Redirect(http.StatusInternalServerError, "https://www.nerdplanet.app") + return + } + + if res.Ok { + c.Redirect(http.StatusFound, "https://www.nerdplanet.app") + } else { + c.Redirect(http.StatusBadRequest, "https://www.nerdplanet.app") + } +} diff --git a/infra/router/router.go b/infra/router/router.go index 8dd0b8f..0445cba 100644 --- a/infra/router/router.go +++ b/infra/router/router.go @@ -9,11 +9,11 @@ import ( "github.com/gin-gonic/gin" swaggerfiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" - docs "github.com/team-nerd-planet/api-server/docs" "github.com/team-nerd-planet/api-server/infra/config" "github.com/team-nerd-planet/api-server/infra/router/handler" "github.com/team-nerd-planet/api-server/infra/router/middleware" "github.com/team-nerd-planet/api-server/internal/controller/rest" + docs "github.com/team-nerd-planet/api-server/third_party/docs" ) type Router struct { @@ -22,7 +22,7 @@ type Router struct { conf *config.Config } -func NewRouter(conf *config.Config, itemCtrl rest.ItemController, tabCtrl rest.TagController) Router { +func NewRouter(conf *config.Config, itemCtrl rest.ItemController, tagCtrl rest.TagController, subscriptionCtrl rest.SubscriptionController) Router { if conf.Rest.Mode == "release" { gin.SetMode(gin.ReleaseMode) } @@ -44,8 +44,14 @@ func NewRouter(conf *config.Config, itemCtrl rest.ItemController, tabCtrl rest.T tag := v1.Group("/tag") { - tag.GET("/job", cache.CachePage(s, time.Hour, func(c *gin.Context) { handler.ListJobTags(c, tabCtrl) })) - tag.GET("/skill", cache.CachePage(s, time.Hour, func(c *gin.Context) { handler.ListSkillTags(c, tabCtrl) })) + tag.GET("/job", cache.CachePage(s, time.Hour, func(c *gin.Context) { handler.ListJobTags(c, tagCtrl) })) + tag.GET("/skill", cache.CachePage(s, time.Hour, func(c *gin.Context) { handler.ListSkillTags(c, tagCtrl) })) + } + + subscription := v1.Group("/subscription") + { + subscription.POST("/apply", func(c *gin.Context) { handler.Apply(c, subscriptionCtrl) }) + subscription.GET("/approve", func(c *gin.Context) { handler.Approve(c, subscriptionCtrl) }) } } diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 922f967..b6dc164 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -5,4 +5,4 @@ import ( "github.com/team-nerd-planet/api-server/internal/controller/rest" ) -var ControllerSet = wire.NewSet(rest.NewItemController, rest.NewTagController) +var ControllerSet = wire.NewSet(rest.NewItemController, rest.NewTagController, rest.NewSubscriptionController) diff --git a/internal/controller/rest/dto/item_dto/item_find_all_dto.go b/internal/controller/rest/dto/item_dto/find_all_dto.go similarity index 100% rename from internal/controller/rest/dto/item_dto/item_find_all_dto.go rename to internal/controller/rest/dto/item_dto/find_all_dto.go diff --git a/internal/controller/rest/dto/subscription_dto/apply_dto.go b/internal/controller/rest/dto/subscription_dto/apply_dto.go new file mode 100644 index 0000000..49e01e0 --- /dev/null +++ b/internal/controller/rest/dto/subscription_dto/apply_dto.go @@ -0,0 +1,34 @@ +package subscription_dto + +import "github.com/team-nerd-planet/api-server/internal/entity" + +type ApplyReq struct { + Email string `json:"email" binding:"required,email"` // 이메일 + Name *string `json:"name" binding:"omitempty"` // 이름 + Division *string `json:"division" binding:"omitempty"` // 소속 + PreferredCompanyArr []int64 `json:"preferred_company_arr" binding:"required"` // 회사 DB ID 배열 + PreferredCompanySizeArr []entity.CompanySizeType `json:"preferred_companySize_arr" binding:"required"` // 회사 규모 배열 (0:스타트업, 1:중소기업, 2:중견기업, 3:대기업, 4:외국계) + PreferredJobArr []int64 `json:"preferred_job_arr" binding:"required"` // 직무 DB ID 배열 + PreferredSkillArr []int64 `json:"preferred_skill_arr" binding:"required"` // 스킬 DB ID 배열 +} + +type ApplyRes struct { + Ok bool `json:"ok"` // 구독 신청 메일 전송 결과 +} + +func (asr ApplyReq) NewSubscription() entity.Subscription { + companySizeArr := make([]int64, len(asr.PreferredCompanySizeArr)) + for i, companySize := range asr.PreferredCompanySizeArr { + companySizeArr[i] = int64(companySize) + } + + return entity.Subscription{ + Email: asr.Email, + Name: asr.Name, + Division: asr.Division, + PreferredCompanyArr: asr.PreferredCompanyArr, + PreferredCompanySizeArr: companySizeArr, + PreferredJobArr: asr.PreferredJobArr, + PreferredSkillArr: asr.PreferredSkillArr, + } +} diff --git a/internal/controller/rest/dto/subscription_dto/approve_dto.go b/internal/controller/rest/dto/subscription_dto/approve_dto.go new file mode 100644 index 0000000..ac04c77 --- /dev/null +++ b/internal/controller/rest/dto/subscription_dto/approve_dto.go @@ -0,0 +1,9 @@ +package subscription_dto + +type ApproveReq struct { + Token string `form:"token" binding:"required"` +} + +type ApproveRes struct { + Ok bool `json:"ok"` // 구독 인증 결과 +} \ No newline at end of file diff --git a/internal/controller/rest/subscription_controller.go b/internal/controller/rest/subscription_controller.go new file mode 100644 index 0000000..7e8bce1 --- /dev/null +++ b/internal/controller/rest/subscription_controller.go @@ -0,0 +1,48 @@ +package rest + +import ( + "github.com/team-nerd-planet/api-server/internal/controller/rest/dto/subscription_dto" + "github.com/team-nerd-planet/api-server/internal/usecase/subscription" +) + +type SubscriptionController struct { + subscriptionUcase subscription.SubscriptionUsecase +} + +func NewSubscriptionController(subscriptionUcase subscription.SubscriptionUsecase) SubscriptionController { + return SubscriptionController{ + subscriptionUcase: subscriptionUcase, + } +} + +func (sc SubscriptionController) Apply(req subscription_dto.ApplyReq) (*subscription_dto.ApplyRes, bool) { + subscription, ok := sc.subscriptionUcase.ApplySubscription(req.NewSubscription()) + if !ok { + return nil, false + } + + result := false + if subscription != nil { + result = true + } + + return &subscription_dto.ApplyRes{ + Ok: result, + }, true +} + +func (sc SubscriptionController) Approve(req subscription_dto.ApproveReq) (*subscription_dto.ApproveRes, bool) { + subscription, ok := sc.subscriptionUcase.Subscribe(req.Token) + if !ok { + return nil, false + } + + result := false + if subscription != nil { + result = true + } + + return &subscription_dto.ApproveRes{ + Ok: result, + }, true +} diff --git a/internal/entity/feed.go b/internal/entity/feed.go index 4b66ea7..0a798a2 100644 --- a/internal/entity/feed.go +++ b/internal/entity/feed.go @@ -5,11 +5,11 @@ import "time" type CompanySizeType int const ( - STARTUP CompanySizeType = iota //스타트업 - SMALL //중소기업 - MEDIUM //중견기업 - LARGE //대기업 - FOREIGN //외국계 + STARTUP CompanySizeType = iota // 스타트업 + SMALL // 중소기업 + MEDIUM // 중견기업 + LARGE // 대기업 + FOREIGN // 외국계 ) type Feed struct { diff --git a/internal/entity/subscription.go b/internal/entity/subscription.go new file mode 100644 index 0000000..91772c4 --- /dev/null +++ b/internal/entity/subscription.go @@ -0,0 +1,21 @@ +package entity + +import "github.com/lib/pq" + +type Subscription struct { + ID uint `gorm:"column:id;primarykey"` + Email string `gorm:"column:email;type:varchar;not null;unique"` + Name *string `gorm:"column:name;type:varchar"` + Division *string `gorm:"column:division;type:varchar"` + PreferredCompanyArr pq.Int64Array `gorm:"column:preferred_company_arr;type:int8[];not null"` + PreferredCompanySizeArr pq.Int64Array `gorm:"column:preferred_company_size_arr;type:int8[];not null"` + PreferredJobArr pq.Int64Array `gorm:"column:preferred_job_arr;type:int8[];not null"` + PreferredSkillArr pq.Int64Array `gorm:"column:preferred_skill_arr;type:int8[];not null"` +} + +type SubscriptionRepo interface { + ExistEmail(email string) (*int64, error) + Create(subscription Subscription) (*Subscription, error) + Update(id int64, subscription Subscription) (*Subscription, error) + Delete(id int64) (*Subscription, error) +} diff --git a/internal/usecase/subscription/subscriber_ucase.go b/internal/usecase/subscription/subscriber_ucase.go new file mode 100644 index 0000000..e1c07db --- /dev/null +++ b/internal/usecase/subscription/subscriber_ucase.go @@ -0,0 +1,199 @@ +package subscription + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "log/slog" + "net/smtp" + "path" + "runtime" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/team-nerd-planet/api-server/infra/config" + "github.com/team-nerd-planet/api-server/internal/entity" +) + +type SubscriptionUsecase struct { + subscriptionRepo entity.SubscriptionRepo + conf *config.Config +} + +func NewSubscriptionUsecase( + subscriptionRepo entity.SubscriptionRepo, + config *config.Config, +) SubscriptionUsecase { + return SubscriptionUsecase{ + subscriptionRepo: subscriptionRepo, + conf: config, + } +} + +func (su SubscriptionUsecase) ApplySubscription(subscription entity.Subscription) (*entity.Subscription, bool) { + id, err := su.subscriptionRepo.ExistEmail(subscription.Email) + if err != nil { + slog.Error(err.Error()) + return nil, false + } + + token := emailToken{Subscription: subscription} + token.RegisteredClaims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(1 * time.Hour)) + + if id == nil { + token.Type = SUBSCRIBE + } else { + token.Type = RESUBSCRIBE + token.Subscription.ID = uint(*id) + } + + tokenStr, err := generateEmailToken(token, su.conf.Jwt.SecretKey) + if err != nil { + slog.Error(err.Error()) + return nil, false + } + + if err := sendSubscribeMail(su.conf.Smtp.Host, su.conf.Smtp.Port, su.conf.Smtp.UserName, su.conf.Smtp.Password, subscription.Email, tokenStr); err != nil { + return nil, false + } + + return &subscription, true +} + +func (su SubscriptionUsecase) CancelSubscription(email string) (*entity.Subscription, bool) { + //이메일 조회 + //JWT 토큰 생성 + //이메일이 없으면 실패 반환 + //이메일이 있으면 DELETE 이메일 전송 + return nil, true +} + +func (su SubscriptionUsecase) Subscribe(token string) (*entity.Subscription, bool) { + var ( + emailToken *emailToken + err error + subscription *entity.Subscription + ) + + emailToken, err = verifyEmailToken(token, su.conf.Jwt.SecretKey) + if err != nil { + slog.Error(err.Error()) + return nil, false + } + + switch emailToken.Type { + case SUBSCRIBE: + subscription, err = su.subscriptionRepo.Create(emailToken.Subscription) + case RESUBSCRIBE: + subscription, err = su.subscriptionRepo.Update(int64(emailToken.Subscription.ID), emailToken.Subscription) + case UNSUBSCRIBE: + subscription, err = su.subscriptionRepo.Delete(int64(emailToken.Subscription.ID)) + default: + return nil, false + } + + if err != nil { + slog.Error(err.Error()) + return nil, false + } + + return subscription, true +} + +func (su SubscriptionUsecase) Resubscribe(subscription entity.Subscription) (*entity.Subscription, bool) { + return nil, false +} + +func (su SubscriptionUsecase) Unsubscribe(email string) (*entity.Subscription, bool) { + return nil, false +} + +func sendSubscribeMail(host string, port int, userName, password, email, token string) error { + data := struct { + Token string + }{ + Token: token, + } + + _, b, _, _ := runtime.Caller(0) + configDirPath := path.Join(path.Dir(b)) + t, err := template.ParseFiles(fmt.Sprintf("%s/template/subscription.html", configDirPath)) + if err != nil { + slog.Error(err.Error()) + return err + } + + var body bytes.Buffer + if err := t.Execute(&body, data); err != nil { + slog.Error(err.Error()) + return err + } + + auth := smtp.PlainAuth("", userName, password, host) + from := userName + to := []string{email} + subject := "Subject: Nerd Planet 메일 인증\n" + mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" + msg := []byte(subject + mime + body.String()) + err = smtp.SendMail(fmt.Sprintf("%s:%d", host, port), auth, from, to, msg) + if err != nil { + return err + } + + return nil +} + +var ( + errTokenExpired = errors.New("token has invalid claims: token is expired") + errUnexpectedSigningMethod = errors.New("unexpected signing method: HMAC-SHA") + errSignatureInvalid = errors.New("token signature is invalid: signature is invalid") +) + +type tokenType int + +const ( + SUBSCRIBE tokenType = iota + RESUBSCRIBE + UNSUBSCRIBE +) + +type emailToken struct { + Type tokenType `json:"type"` + Subscription entity.Subscription `json:"subscription"` + jwt.RegisteredClaims +} + +func generateEmailToken(token emailToken, secretKey string) (string, error) { + newToken := jwt.NewWithClaims(jwt.SigningMethodHS256, token) + return newToken.SignedString([]byte(secretKey)) +} + +func verifyEmailToken(tokenString string, secretKey string) (*emailToken, error) { + token, err := jwt.ParseWithClaims(tokenString, &emailToken{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errUnexpectedSigningMethod + } + + expiration, err := token.Claims.GetExpirationTime() + if err != nil { + return nil, err + } + + if expiration.Time.Unix() < time.Now().Unix() { + return nil, errTokenExpired + } + + return []byte(secretKey), nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*emailToken) + if !ok { + return nil, errSignatureInvalid + } + + return claims, nil +} diff --git a/internal/usecase/subscription/template/subscription.html b/internal/usecase/subscription/template/subscription.html new file mode 100644 index 0000000..c91eedc --- /dev/null +++ b/internal/usecase/subscription/template/subscription.html @@ -0,0 +1,45 @@ + + + + + + + Nerd Planet Auth Email + + + + +
+
+ 이메일 인증을 하려면 여기를 눌러주세요. +
+
+ + + \ No newline at end of file diff --git a/internal/usecase/usecase.go b/internal/usecase/usecase.go index 4e2483a..38dda06 100644 --- a/internal/usecase/usecase.go +++ b/internal/usecase/usecase.go @@ -3,7 +3,8 @@ package usecase import ( "github.com/google/wire" "github.com/team-nerd-planet/api-server/internal/usecase/item" + "github.com/team-nerd-planet/api-server/internal/usecase/subscription" "github.com/team-nerd-planet/api-server/internal/usecase/tag" ) -var UsecaseSet = wire.NewSet(item.NewItemUsecase, tag.NewJobTagUsecase, tag.NewSkillTagUsecase) +var UsecaseSet = wire.NewSet(item.NewItemUsecase, tag.NewJobTagUsecase, tag.NewSkillTagUsecase, subscription.NewSubscriptionUsecase) diff --git a/docs/docs.go b/third_party/docs/docs.go similarity index 70% rename from docs/docs.go rename to third_party/docs/docs.go index c8870b7..e6f0422 100644 --- a/docs/docs.go +++ b/third_party/docs/docs.go @@ -84,7 +84,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes" + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto.Paginated-array_github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto_FindAllItemRes" } }, "400": { @@ -108,6 +108,52 @@ const docTemplate = `{ } } }, + "/v1/subscription/apply": { + "post": { + "description": "apply for subscription", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscription" + ], + "summary": "Apply subscription", + "parameters": [ + { + "description": "contents for applying for subscription.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_infra_router_util.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_infra_router_util.HTTPError" + } + } + } + } + }, "/v1/tag/job": { "get": { "description": "list job tags", @@ -212,6 +258,29 @@ const docTemplate = `{ } } }, + "github_com_team-nerd-planet_api-server_internal_controller_rest_dto.Paginated-array_github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto_FindAllItemRes": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "total_page": { + "type": "integer" + } + } + }, "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes": { "type": "object", "properties": { @@ -275,6 +344,67 @@ const docTemplate = `{ } } }, + "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyReq": { + "type": "object", + "required": [ + "email", + "preferred_companySize_arr", + "preferred_company_arr", + "preferred_job_arr", + "preferred_skill_arr" + ], + "properties": { + "division": { + "description": "소속", + "type": "string" + }, + "email": { + "description": "이메일", + "type": "string" + }, + "name": { + "description": "이름", + "type": "string" + }, + "preferred_companySize_arr": { + "description": "회사 규모 배열 (0:스타트업, 1:중소기업, 2:중견기업, 3:대기업, 4:외국계)", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_entity.CompanySizeType" + } + }, + "preferred_company_arr": { + "description": "회사 DB ID 배열", + "type": "array", + "items": { + "type": "integer" + } + }, + "preferred_job_arr": { + "description": "직무 DB ID 배열", + "type": "array", + "items": { + "type": "integer" + } + }, + "preferred_skill_arr": { + "description": "스킬 DB ID 배열", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyRes": { + "type": "object", + "properties": { + "ok": { + "description": "구독 신청 메일 전송 결과", + "type": "boolean" + } + } + }, "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_tag_dto.FindAllJobTagRes": { "type": "object", "properties": { diff --git a/docs/swagger.json b/third_party/docs/swagger.json similarity index 68% rename from docs/swagger.json rename to third_party/docs/swagger.json index 2992b8a..049fe14 100644 --- a/docs/swagger.json +++ b/third_party/docs/swagger.json @@ -79,7 +79,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes" + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto.Paginated-array_github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto_FindAllItemRes" } }, "400": { @@ -103,6 +103,52 @@ } } }, + "/v1/subscription/apply": { + "post": { + "description": "apply for subscription", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscription" + ], + "summary": "Apply subscription", + "parameters": [ + { + "description": "contents for applying for subscription.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_infra_router_util.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_infra_router_util.HTTPError" + } + } + } + } + }, "/v1/tag/job": { "get": { "description": "list job tags", @@ -207,6 +253,29 @@ } } }, + "github_com_team-nerd-planet_api-server_internal_controller_rest_dto.Paginated-array_github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto_FindAllItemRes": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "total_page": { + "type": "integer" + } + } + }, "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes": { "type": "object", "properties": { @@ -270,6 +339,67 @@ } } }, + "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyReq": { + "type": "object", + "required": [ + "email", + "preferred_companySize_arr", + "preferred_company_arr", + "preferred_job_arr", + "preferred_skill_arr" + ], + "properties": { + "division": { + "description": "소속", + "type": "string" + }, + "email": { + "description": "이메일", + "type": "string" + }, + "name": { + "description": "이름", + "type": "string" + }, + "preferred_companySize_arr": { + "description": "회사 규모 배열 (0:스타트업, 1:중소기업, 2:중견기업, 3:대기업, 4:외국계)", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_team-nerd-planet_api-server_internal_entity.CompanySizeType" + } + }, + "preferred_company_arr": { + "description": "회사 DB ID 배열", + "type": "array", + "items": { + "type": "integer" + } + }, + "preferred_job_arr": { + "description": "직무 DB ID 배열", + "type": "array", + "items": { + "type": "integer" + } + }, + "preferred_skill_arr": { + "description": "스킬 DB ID 배열", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyRes": { + "type": "object", + "properties": { + "ok": { + "description": "구독 신청 메일 전송 결과", + "type": "boolean" + } + } + }, "github_com_team-nerd-planet_api-server_internal_controller_rest_dto_tag_dto.FindAllJobTagRes": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/third_party/docs/swagger.yaml similarity index 66% rename from docs/swagger.yaml rename to third_party/docs/swagger.yaml index df39418..fb6a772 100644 --- a/docs/swagger.yaml +++ b/third_party/docs/swagger.yaml @@ -8,6 +8,21 @@ definitions: message: type: string type: object + ? github_com_team-nerd-planet_api-server_internal_controller_rest_dto.Paginated-array_github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto_FindAllItemRes + : properties: + data: + items: + $ref: '#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes' + type: array + page: + type: integer + per_page: + type: integer + total_count: + type: integer + total_page: + type: integer + type: object github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes: properties: company_size: @@ -52,6 +67,50 @@ definitions: type: integer type: array type: object + github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyReq: + properties: + division: + description: 소속 + type: string + email: + description: 이메일 + type: string + name: + description: 이름 + type: string + preferred_company_arr: + description: 회사 DB ID 배열 + items: + type: integer + type: array + preferred_companySize_arr: + description: 회사 규모 배열 (0:스타트업, 1:중소기업, 2:중견기업, 3:대기업, 4:외국계) + items: + $ref: '#/definitions/github_com_team-nerd-planet_api-server_internal_entity.CompanySizeType' + type: array + preferred_job_arr: + description: 직무 DB ID 배열 + items: + type: integer + type: array + preferred_skill_arr: + description: 스킬 DB ID 배열 + items: + type: integer + type: array + required: + - email + - preferred_companySize_arr + - preferred_company_arr + - preferred_job_arr + - preferred_skill_arr + type: object + github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyRes: + properties: + ok: + description: 구독 신청 메일 전송 결과 + type: boolean + type: object github_com_team-nerd-planet_api-server_internal_controller_rest_dto_tag_dto.FindAllJobTagRes: properties: id: @@ -144,7 +203,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto.FindAllItemRes' + $ref: '#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto.Paginated-array_github_com_team-nerd-planet_api-server_internal_controller_rest_dto_item_dto_FindAllItemRes' "400": description: Bad Request schema: @@ -160,6 +219,36 @@ paths: summary: List item tags: - item + /v1/subscription/apply: + post: + consumes: + - application/json + description: apply for subscription + parameters: + - description: contents for applying for subscription. + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_team-nerd-planet_api-server_internal_controller_rest_dto_subscription_dto.ApplyRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_team-nerd-planet_api-server_infra_router_util.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_team-nerd-planet_api-server_infra_router_util.HTTPError' + summary: Apply subscription + tags: + - subscription /v1/tag/job: get: consumes: