package v1 import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/labstack/echo/v4" "github.com/pkg/errors" "github.com/usememos/memos/common/log" "github.com/usememos/memos/common/util" "github.com/usememos/memos/store" "go.uber.org/zap" ) // Visibility is the type of a visibility. type Visibility string const ( // Public is the PUBLIC visibility. Public Visibility = "PUBLIC" // Protected is the PROTECTED visibility. Protected Visibility = "PROTECTED" // Private is the PRIVATE visibility. Private Visibility = "PRIVATE" ) func (v Visibility) String() string { switch v { case Public: return "PUBLIC" case Protected: return "PROTECTED" case Private: return "PRIVATE" } return "PRIVATE" } type Memo struct { ID int32 `json:"id"` // Standard fields RowStatus RowStatus `json:"rowStatus"` CreatorID int32 `json:"creatorId"` CreatedTs int64 `json:"createdTs"` UpdatedTs int64 `json:"updatedTs"` // Domain specific fields DisplayTs int64 `json:"displayTs"` Content string `json:"content"` Visibility Visibility `json:"visibility"` Pinned bool `json:"pinned"` // Related fields CreatorName string `json:"creatorName"` CreatorUsername string `json:"creatorUsername"` ResourceList []*Resource `json:"resourceList"` RelationList []*MemoRelation `json:"relationList"` } type CreateMemoRequest struct { // Standard fields CreatorID int32 `json:"-"` CreatedTs *int64 `json:"createdTs"` // Domain specific fields Visibility Visibility `json:"visibility"` Content string `json:"content"` // Related fields ResourceIDList []int32 `json:"resourceIdList"` RelationList []*UpsertMemoRelationRequest `json:"relationList"` } type PatchMemoRequest struct { ID int32 `json:"-"` // Standard fields CreatedTs *int64 `json:"createdTs"` UpdatedTs *int64 RowStatus *RowStatus `json:"rowStatus"` // Domain specific fields Content *string `json:"content"` Visibility *Visibility `json:"visibility"` // Related fields ResourceIDList []int32 `json:"resourceIdList"` RelationList []*UpsertMemoRelationRequest `json:"relationList"` } type FindMemoRequest struct { ID *int32 // Standard fields RowStatus *RowStatus CreatorID *int32 // Domain specific fields Pinned *bool ContentSearch []string VisibilityList []Visibility // Pagination Limit *int Offset *int } // maxContentLength means the max memo content bytes is 1MB. const maxContentLength = 1 << 30 func (s *APIV1Service) registerMemoRoutes(g *echo.Group) { g.GET("/memo", s.GetMemoList) g.POST("/memo", s.CreateMemo) g.GET("/memo/all", s.GetAllMemos) g.GET("/memo/stats", s.GetMemoStats) g.GET("/memo/:memoId", s.GetMemo) g.PATCH("/memo/:memoId", s.UpdateMemo) g.DELETE("/memo/:memoId", s.DeleteMemo) } // GetMemoList godoc // // @Summary Get a list of memos matching optional filters // @Tags memo // @Produce json // @Param creatorId query int false "Creator ID" // @Param creatorUsername query string false "Creator username" // @Param rowStatus query store.RowStatus false "Row status" // @Param pinned query bool false "Pinned" // @Param tag query string false "Search for tag. Do not append #" // @Param content query string false "Search for content" // @Param limit query int false "Limit" // @Param offset query int false "Offset" // @Success 200 {object} []store.Memo "Memo list" // @Failure 400 {object} nil "Missing user to find memo" // @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch memo list | Failed to compose memo response" // @Security ApiKeyAuth // @Router /api/v1/memo [GET] func (s *APIV1Service) GetMemoList(c echo.Context) error { ctx := c.Request().Context() findMemoMessage := &store.FindMemo{} if userID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil { findMemoMessage.CreatorID = &userID } if username := c.QueryParam("creatorUsername"); username != "" { user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) if user != nil { findMemoMessage.CreatorID = &user.ID } } currentUserID, ok := c.Get(userIDContextKey).(int32) if !ok { // Anonymous use should only fetch PUBLIC memos with specified user if findMemoMessage.CreatorID == nil { return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo") } findMemoMessage.VisibilityList = []store.Visibility{store.Public} } else { // Authorized user can fetch all PUBLIC/PROTECTED memo visibilityList := []store.Visibility{store.Public, store.Protected} // If Creator is authorized user (as default), PRIVATE memo is OK if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID { findMemoMessage.CreatorID = ¤tUserID visibilityList = append(visibilityList, store.Private) } findMemoMessage.VisibilityList = visibilityList } rowStatus := store.RowStatus(c.QueryParam("rowStatus")) if rowStatus != "" { findMemoMessage.RowStatus = &rowStatus } pinnedStr := c.QueryParam("pinned") if pinnedStr != "" { pinned := pinnedStr == "true" findMemoMessage.Pinned = &pinned } contentSearch := []string{} tag := c.QueryParam("tag") if tag != "" { contentSearch = append(contentSearch, "#"+tag) } contentSlice := c.QueryParams()["content"] if len(contentSlice) > 0 { contentSearch = append(contentSearch, contentSlice...) } findMemoMessage.ContentSearch = contentSearch if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { findMemoMessage.Limit = &limit } if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { findMemoMessage.Offset = &offset } memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) } if memoDisplayWithUpdatedTs { findMemoMessage.OrderByUpdatedTs = true } list, err := s.Store.ListMemos(ctx, findMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err) } memoResponseList := []*Memo{} for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } memoResponseList = append(memoResponseList, memoResponse) } return c.JSON(http.StatusOK, memoResponseList) } // CreateMemo godoc // // @Summary Create a memo // @Description Visibility can be PUBLIC, PROTECTED or PRIVATE // @Description *You should omit fields to use their default values // @Tags memo // @Accept json // @Produce json // @Param body body CreateMemoRequest true "Request object." // @Success 200 {object} store.Memo "Stored memo" // @Failure 400 {object} nil "Malformatted post memo request | Content size overflow, up to 1MB" // @Failure 401 {object} nil "Missing user in session" // @Failure 404 {object} nil "User not found | Memo not found: %d" // @Failure 500 {object} nil "Failed to find user setting | Failed to unmarshal user setting value | Failed to find system setting | Failed to unmarshal system setting | Failed to find user | Failed to create memo | Failed to create activity | Failed to upsert memo resource | Failed to upsert memo relation | Failed to compose memo | Failed to compose memo response" // @Security ApiKeyAuth // @Router /api/v1/memo [POST] // // NOTES: // - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo. func (s *APIV1Service) CreateMemo(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(userIDContextKey).(int32) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") } createMemoRequest := &CreateMemoRequest{} if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err) } if len(createMemoRequest.Content) > maxContentLength { return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB") } if createMemoRequest.Visibility == "" { userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{ UserID: &userID, Key: UserSettingMemoVisibilityKey.String(), }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err) } if userMemoVisibilitySetting != nil { memoVisibility := Private err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err) } createMemoRequest.Visibility = memoVisibility } else { // Private is the default memo visibility. createMemoRequest.Visibility = Private } } // Find disable public memos system setting. disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ Name: SystemSettingDisablePublicMemosName.String(), }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) } if disablePublicMemosSystemSetting != nil { disablePublicMemos := false err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err) } if disablePublicMemos { user, err := s.Store.GetUser(ctx, &store.FindUser{ ID: &userID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) } if user == nil { return echo.NewHTTPError(http.StatusNotFound, "User not found") } // Enforce normal user to create private memo if public memos are disabled. if user.Role == store.RoleUser { createMemoRequest.Visibility = Private } } } createMemoRequest.CreatorID = userID memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest)) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err) } if err := s.createMemoCreateActivity(ctx, memo); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) } for _, resourceID := range createMemoRequest.ResourceIDList { if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ MemoID: memo.ID, ResourceID: resourceID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) } } for _, memoRelationUpsert := range createMemoRequest.RelationList { if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ MemoID: memo.ID, RelatedMemoID: memoRelationUpsert.RelatedMemoID, Type: store.MemoRelationType(memoRelationUpsert.Type), }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) } } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memo.ID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID)) } memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } // send notification by telegram bot if memo is not Private if memoResponse.Visibility != Private { // fetch all telegram UserID userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{Key: UserSettingTelegramUserIDKey.String()}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to ListUserSettings").SetInternal(err) } for _, userSetting := range userSettings { // parse telegram UserID setting value into a int64 var tgUserIDStr string err := json.Unmarshal([]byte(userSetting.Value), &tgUserIDStr) if err != nil { log.Error("failed to parse Telegram UserID", zap.Error(err)) continue } tgUserID, err := strconv.ParseInt(tgUserIDStr, 10, 64) if err != nil { log.Error("failed to parse Telegram UserID", zap.Error(err)) continue } // send notification to telegram content := memoResponse.CreatorName + " Says:\n\n" + memoResponse.Content _, err = s.telegramBot.SendMessage(ctx, tgUserID, content) if err != nil { log.Error("Failed to send Telegram notification", zap.Error(err)) continue } } } return c.JSON(http.StatusOK, memoResponse) } // GetAllMemos godoc // // @Summary Get a list of public memos matching optional filters // @Description This should also list protected memos if the user is logged in // @Description Authentication is optional // @Tags memo // @Produce json // @Param limit query int false "Limit" // @Param offset query int false "Offset" // @Success 200 {object} []store.Memo "Memo list" // @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to fetch all memo list | Failed to compose memo response" // @Security ApiKeyAuth // @Router /api/v1/memo/all [GET] // // NOTES: // - creatorUsername is listed at ./web/src/helpers/api.ts:82, but it's not present here func (s *APIV1Service) GetAllMemos(c echo.Context) error { ctx := c.Request().Context() findMemoMessage := &store.FindMemo{} _, ok := c.Get(userIDContextKey).(int32) if !ok { findMemoMessage.VisibilityList = []store.Visibility{store.Public} } else { findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} } if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { findMemoMessage.Limit = &limit } if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { findMemoMessage.Offset = &offset } // Only fetch normal status memos. normalStatus := store.Normal findMemoMessage.RowStatus = &normalStatus memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) } if memoDisplayWithUpdatedTs { findMemoMessage.OrderByUpdatedTs = true } list, err := s.Store.ListMemos(ctx, findMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err) } memoResponseList := []*Memo{} for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } memoResponseList = append(memoResponseList, memoResponse) } return c.JSON(http.StatusOK, memoResponseList) } // GetMemoStats godoc // // @Summary Get memo stats by creator ID or username // @Description Used to generate the heatmap // @Tags memo // @Produce json // @Param creatorId query int false "Creator ID" // @Param creatorUsername query string false "Creator username" // @Success 200 {object} []int "Memo createdTs list" // @Failure 400 {object} nil "Missing user id to find memo" // @Failure 500 {object} nil "Failed to get memo display with updated ts setting value | Failed to find memo list | Failed to compose memo response" // @Router /api/v1/memo/stats [GET] func (s *APIV1Service) GetMemoStats(c echo.Context) error { ctx := c.Request().Context() normalStatus := store.Normal findMemoMessage := &store.FindMemo{ RowStatus: &normalStatus, } if creatorID, err := util.ConvertStringToInt32(c.QueryParam("creatorId")); err == nil { findMemoMessage.CreatorID = &creatorID } if username := c.QueryParam("creatorUsername"); username != "" { user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) if user != nil { findMemoMessage.CreatorID = &user.ID } } if findMemoMessage.CreatorID == nil { return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo") } currentUserID, ok := c.Get(userIDContextKey).(int32) if !ok { findMemoMessage.VisibilityList = []store.Visibility{store.Public} } else { if *findMemoMessage.CreatorID != currentUserID { findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} } else { findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private} } } memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) } if memoDisplayWithUpdatedTs { findMemoMessage.OrderByUpdatedTs = true } list, err := s.Store.ListMemos(ctx, findMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) } displayTsList := []int64{} if memoDisplayWithUpdatedTs { for _, memo := range list { displayTsList = append(displayTsList, memo.CreatedTs) } } else { for _, memo := range list { displayTsList = append(displayTsList, memo.UpdatedTs) } } return c.JSON(http.StatusOK, displayTsList) } // GetMemo godoc // // @Summary Get memo by ID // @Tags memo // @Produce json // @Param memoId path int true "Memo ID" // @Success 200 {object} []store.Memo "Memo list" // @Failure 400 {object} nil "ID is not a number: %s" // @Failure 401 {object} nil "Missing user in session" // @Failure 403 {object} nil "this memo is private only | this memo is protected, missing user in session // @Failure 404 {object} nil "Memo not found: %d" // @Failure 500 {object} nil "Failed to find memo by ID: %v | Failed to compose memo response" // @Router /api/v1/memo/{memoId} [GET] func (s *APIV1Service) GetMemo(c echo.Context) error { ctx := c.Request().Context() memoID, err := util.ConvertStringToInt32(c.Param("memoId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } userID, ok := c.Get(userIDContextKey).(int32) if memo.Visibility == store.Private { if !ok || memo.CreatorID != userID { return echo.NewHTTPError(http.StatusForbidden, "this memo is private only") } } else if memo.Visibility == store.Protected { if !ok { return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session") } } memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } return c.JSON(http.StatusOK, memoResponse) } // DeleteMemo godoc // // @Summary Delete memo by ID // @Tags memo // @Produce json // @Param memoId path int true "Memo ID to delete" // @Success 200 {boolean} true "Memo deleted" // @Failure 400 {object} nil "ID is not a number: %s" // @Failure 401 {object} nil "Missing user in session | Unauthorized" // @Failure 404 {object} nil "Memo not found: %d" // @Failure 500 {object} nil "Failed to find memo | Failed to delete memo ID: %v" // @Security ApiKeyAuth // @Router /api/v1/memo/{memoId} [DELETE] func (s *APIV1Service) DeleteMemo(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(userIDContextKey).(int32) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") } memoID, err := util.ConvertStringToInt32(c.Param("memoId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } if memo.CreatorID != userID { return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ ID: memoID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err) } return c.JSON(http.StatusOK, true) } // UpdateMemo godoc // // @Summary Update a memo // @Description Visibility can be PUBLIC, PROTECTED or PRIVATE // @Description *You should omit fields to use their default values // @Tags memo // @Accept json // @Produce json // @Param memoId path int true "ID of memo to update" // @Param body body PatchMemoRequest true "Patched object." // @Success 200 {object} store.Memo "Stored memo" // @Failure 400 {object} nil "ID is not a number: %s | Malformatted patch memo request | Content size overflow, up to 1MB" // @Failure 401 {object} nil "Missing user in session | Unauthorized" // @Failure 404 {object} nil "Memo not found: %d" // @Failure 500 {object} nil "Failed to find memo | Failed to patch memo | Failed to upsert memo resource | Failed to delete memo resource | Failed to compose memo response" // @Security ApiKeyAuth // @Router /api/v1/memo/{memoId} [PATCH] // // NOTES: // - It's currently possible to create phantom resources and relations. Phantom relations will trigger backend 404's when fetching memo. // - Passing 0 to createdTs and updatedTs will set them to 0 in the database, which is probably unwanted. func (s *APIV1Service) UpdateMemo(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(userIDContextKey).(int32) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") } memoID, err := util.ConvertStringToInt32(c.Param("memoId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } if memo.CreatorID != userID { return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } currentTs := time.Now().Unix() patchMemoRequest := &PatchMemoRequest{ ID: memoID, UpdatedTs: ¤tTs, } if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err) } if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength { return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err) } updateMemoMessage := &store.UpdateMemo{ ID: memoID, CreatedTs: patchMemoRequest.CreatedTs, UpdatedTs: patchMemoRequest.UpdatedTs, Content: patchMemoRequest.Content, } if patchMemoRequest.RowStatus != nil { rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String()) updateMemoMessage.RowStatus = &rowStatus } if patchMemoRequest.Visibility != nil { visibility := store.Visibility(patchMemoRequest.Visibility.String()) updateMemoMessage.Visibility = &visibility } err = s.Store.UpdateMemo(ctx, updateMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err) } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } if patchMemoRequest.ResourceIDList != nil { addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList) for _, resourceID := range addedResourceIDList { if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ MemoID: memo.ID, ResourceID: resourceID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) } } for _, resourceID := range removedResourceIDList { if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{ MemoID: &memo.ID, ResourceID: &resourceID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo resource").SetInternal(err) } } } if patchMemoRequest.RelationList != nil { patchMemoRelationList := make([]*store.MemoRelation, 0) for _, memoRelation := range patchMemoRequest.RelationList { patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{ MemoID: memo.ID, RelatedMemoID: memoRelation.RelatedMemoID, Type: store.MemoRelationType(memoRelation.Type), }) } addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList) for _, memoRelation := range addedMemoRelationList { if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) } } for _, memoRelation := range removedMemoRelationList { if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ MemoID: &memo.ID, RelatedMemoID: &memoRelation.RelatedMemoID, Type: &memoRelation.Type, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err) } } } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } return c.JSON(http.StatusOK, memoResponse) } func (s *APIV1Service) createMemoCreateActivity(ctx context.Context, memo *store.Memo) error { payload := ActivityMemoCreatePayload{ Content: memo.Content, Visibility: memo.Visibility.String(), } payloadBytes, err := json.Marshal(payload) if err != nil { return errors.Wrap(err, "failed to marshal activity payload") } activity, err := s.Store.CreateActivity(ctx, &store.Activity{ CreatorID: memo.CreatorID, Type: ActivityMemoCreate.String(), Level: ActivityInfo.String(), Payload: string(payloadBytes), }) if err != nil || activity == nil { return errors.Wrap(err, "failed to create activity") } return err } func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*Memo, error) { memoResponse := &Memo{ ID: memo.ID, RowStatus: RowStatus(memo.RowStatus.String()), CreatorID: memo.CreatorID, CreatedTs: memo.CreatedTs, UpdatedTs: memo.UpdatedTs, Content: memo.Content, Visibility: Visibility(memo.Visibility.String()), Pinned: memo.Pinned, } // Compose creator name. user, err := s.Store.GetUser(ctx, &store.FindUser{ ID: &memoResponse.CreatorID, }) if err != nil { return nil, err } if user.Nickname != "" { memoResponse.CreatorName = user.Nickname } else { memoResponse.CreatorName = user.Username } memoResponse.CreatorUsername = user.Username // Compose display ts. memoResponse.DisplayTs = memoResponse.CreatedTs // Find memo display with updated ts setting. memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return nil, err } if memoDisplayWithUpdatedTs { memoResponse.DisplayTs = memoResponse.UpdatedTs } relationList := []*MemoRelation{} for _, relation := range memo.RelationList { relationList = append(relationList, convertMemoRelationFromStore(relation)) } memoResponse.RelationList = relationList resourceList := []*Resource{} for _, resourceID := range memo.ResourceIDList { resource, err := s.Store.GetResource(ctx, &store.FindResource{ ID: &resourceID, }) if err != nil { return nil, err } if resource != nil { resourceList = append(resourceList, convertResourceFromStore(resource)) } } memoResponse.ResourceList = resourceList return memoResponse, nil } func (s *APIV1Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) { memoDisplayWithUpdatedTsSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ Name: SystemSettingMemoDisplayWithUpdatedTsName.String(), }) if err != nil { return false, errors.Wrap(err, "failed to find system setting") } memoDisplayWithUpdatedTs := false if memoDisplayWithUpdatedTsSetting != nil { err = json.Unmarshal([]byte(memoDisplayWithUpdatedTsSetting.Value), &memoDisplayWithUpdatedTs) if err != nil { return false, errors.Wrap(err, "failed to unmarshal system setting value") } } return memoDisplayWithUpdatedTs, nil } func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store.Memo { createdTs := time.Now().Unix() if memoCreate.CreatedTs != nil { createdTs = *memoCreate.CreatedTs } return &store.Memo{ CreatorID: memoCreate.CreatorID, CreatedTs: createdTs, Content: memoCreate.Content, Visibility: store.Visibility(memoCreate.Visibility), } } func getMemoRelationListDiff(oldList, newList []*store.MemoRelation) (addedList, removedList []*store.MemoRelation) { oldMap := map[string]bool{} for _, relation := range oldList { oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true } newMap := map[string]bool{} for _, relation := range newList { newMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true } for _, relation := range oldList { key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type) if !newMap[key] { removedList = append(removedList, relation) } } for _, relation := range newList { key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type) if !oldMap[key] { addedList = append(addedList, relation) } } return addedList, removedList } func getIDListDiff(oldList, newList []int32) (addedList, removedList []int32) { oldMap := map[int32]bool{} for _, id := range oldList { oldMap[id] = true } newMap := map[int32]bool{} for _, id := range newList { newMap[id] = true } for id := range oldMap { if !newMap[id] { removedList = append(removedList, id) } } for id := range newMap { if !oldMap[id] { addedList = append(addedList, id) } } return addedList, removedList }