From 5b4f4d8b81d5991da04a97eba2738c4f7bf78f9c Mon Sep 17 00:00:00 2001 From: Felipe Fernandes Date: Sat, 31 Jan 2026 15:12:51 -0300 Subject: [PATCH] Add endpoint to export retrospective as JSON or Markdown --- internal/schedule/schedule.go | 2 ++ internal/server/server.go | 57 +++++++++++++++++++++++++++++++++++ internal/service/convert.go | 46 ++++++++++++++++++++++++++++ types/retrospective.go | 9 ++++++ types/validations.go | 24 ++++++++++++++- 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 internal/service/convert.go diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 9f085e9..ad405e2 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -21,6 +21,7 @@ type ScheduleParams struct { fx.In Service *service.Service Config *config.Config + Logger *zap.Logger Lifecycle fx.Lifecycle } @@ -28,6 +29,7 @@ func New(p ScheduleParams) *Schedule { s := &Schedule{ service: p.Service, config: p.Config, + logger: p.Logger, stopCh: make(chan struct{}), } diff --git a/internal/server/server.go b/internal/server/server.go index 94a1a36..be7672a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "database/sql" "fmt" "net/http" + "strings" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -707,6 +708,61 @@ func (ct *Controller) voteAnswer(c *gin.Context) { } +// exportRetrospective godoc +// +// @Summary Export Retrospective +// @Tags Retrospective +// @Accept json +// @Produce json +// @Produce text/markdown +// @Param export body types.RetrospectiveExportRequest true "Export Retrospective" +// @Success 200 {object} types.Retrospective "Retrospective Object (JSON) or Markdown file" +// @Failure 400 {string} string "Invalid input" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal error" +// @Router /retrospective/export [post] +func (ct *Controller) exportRetrospective(c *gin.Context) { + var input types.RetrospectiveExportRequest + if err := c.BindJSON(&input); err != nil { + ct.logger.Error("error parsing body content", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body content"}) + return + } + + if err := input.Validate(); err != nil { + ct.logger.Error("invalid input", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + retro, err := ct.service.GetRetrospective(c, input.RetrospectiveID) + if err == sql.ErrNoRows { + ct.logger.Error("retrospective not found", zap.String("id", input.RetrospectiveID.String())) + c.JSON(http.StatusNotFound, gin.H{"error": "restrospective not found"}) + return + } + + if err != nil { + ct.logger.Error("error getting retrospective", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + filename := fmt.Sprintf("retrospective-%s", strings.ReplaceAll(retro.Name, " ", "_")) + switch input.ExportType { + case types.ExportTypeMarkdown: + + markdown := ct.service.ConvertRetrospectiveToMarkdown(c, retro) + c.Header("Content-Type", "text/markdown") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.md\"", filename)) + c.String(http.StatusOK, markdown) + default: + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.json\"", filename)) + c.JSON(http.StatusOK, retro) + } +} + // @license.name MIT // @license.url https://github.com/simple-retro/api/blob/master/LICENSE func (ct *Controller) Start() { @@ -739,6 +795,7 @@ func (ct *Controller) Start() { api.GET("/retrospective/:id", ct.getRetrospective) api.PATCH("/retrospective/:id", ct.updateRetrospective) api.DELETE("/retrospective/:id", ct.deleteRetrospective) + api.POST("/retrospective/export", ct.exportRetrospective) api.GET("/hello/:id", ct.subscribeChanges) api.GET("/limits", ct.getLimits) diff --git a/internal/service/convert.go b/internal/service/convert.go new file mode 100644 index 0000000..f7e3a40 --- /dev/null +++ b/internal/service/convert.go @@ -0,0 +1,46 @@ +package service + +import ( + "api/types" + "context" + "fmt" + "sort" + "strings" +) + +func (s *Service) ConvertRetrospectiveToMarkdown(ctx context.Context, retro *types.Retrospective) string { + var sb strings.Builder + + sb.WriteString("# Simple Retro\n\n") + + sb.WriteString("## " + retro.Name + "\n\n") + + if retro.Description != "" { + sb.WriteString(retro.Description + "\n\n") + } + + sb.WriteString("*Created on " + retro.CreatedAt.Format("January 2, 2006 at 3:04 PM") + "*\n\n") + + sb.WriteString("---\n\n") + + for _, question := range retro.Questions { + sb.WriteString("### " + question.Text + "\n\n") + + answers := make([]types.Answer, len(question.Answers)) + copy(answers, question.Answers) + sort.Slice(answers, func(i, j int) bool { + return answers[i].Position < answers[j].Position + }) + + for _, answer := range answers { + if answer.Votes > 0 { + sb.WriteString(fmt.Sprintf("- %s (%d Up votes)\n", answer.Text, answer.Votes)) + } else { + sb.WriteString("- " + answer.Text + "\n") + } + } + sb.WriteString("\n") + } + + return sb.String() +} diff --git a/types/retrospective.go b/types/retrospective.go index 265c2b8..e260b14 100644 --- a/types/retrospective.go +++ b/types/retrospective.go @@ -7,10 +7,14 @@ import ( ) type VoteAction string +type ExportType string const ( VoteAdd VoteAction = "ADD_VOTE" VoteRemove VoteAction = "REMOVE_VOTE" + + ExportTypeJSON ExportType = "JSON" + ExportTypeMarkdown ExportType = "MARKDOWN" ) type Retrospective struct { @@ -55,6 +59,11 @@ type AnswerVoteRequest struct { Action VoteAction `json:"action"` } +type RetrospectiveExportRequest struct { + RetrospectiveID uuid.UUID `json:"retrospective_id"` + ExportType ExportType `json:"export_type"` +} + func (v VoteAction) String() string { return string(v) } diff --git a/types/validations.go b/types/validations.go index ce3142c..acc891e 100644 --- a/types/validations.go +++ b/types/validations.go @@ -1,6 +1,10 @@ package types -import "fmt" +import ( + "fmt" + + "github.com/google/uuid" +) const ( NAME_LIMIT = 100 @@ -106,3 +110,21 @@ func (a *AnswerVoteRequest) Validate() error { return fmt.Errorf("invalid vote action") } } + +func (r RetrospectiveExportRequest) Validate() error { + if r.RetrospectiveID.String() == "" { + return fmt.Errorf("retrospective id cannot be empty") + } + + if r.RetrospectiveID == uuid.Nil { + return fmt.Errorf("retrospective id cannot be nil") + } + + switch r.ExportType { + case ExportTypeJSON, ExportTypeMarkdown: + return nil + default: + return fmt.Errorf("invalid export type") + } + +}