diff --git a/api/api.go b/api/api.go new file mode 100644 index 00000000..c2b5f683 --- /dev/null +++ b/api/api.go @@ -0,0 +1,21 @@ +package api + +// RowStatus is the status for a row. +type RowStatus string + +const ( + // Normal is the status for a normal row. + Normal RowStatus = "NORMAL" + // Archived is the status for an archived row. + Archived RowStatus = "ARCHIVED" +) + +func (e RowStatus) String() string { + switch e { + case Normal: + return "NORMAL" + case Archived: + return "ARCHIVED" + } + return "" +} diff --git a/api/memo.go b/api/memo.go index 001f7e6c..cc4060cf 100644 --- a/api/memo.go +++ b/api/memo.go @@ -4,13 +4,14 @@ type Memo struct { ID int `json:"id"` // Standard fields - CreatedTs int64 `json:"createdTs"` - UpdatedTs int64 `json:"updatedTs"` - RowStatus string `json:"rowStatus"` + RowStatus RowStatus `json:"rowStatus"` + CreatorID int `json:"creatorId"` + CreatedTs int64 `json:"createdTs"` + UpdatedTs int64 `json:"updatedTs"` // Domain specific fields - Content string `json:"content"` - CreatorID int `json:"creatorId"` + Content string `json:"content"` + Pinned bool `json:"pinned"` } type MemoCreate struct { @@ -27,7 +28,7 @@ type MemoPatch struct { ID int // Standard fields - RowStatus *string `json:"rowStatus"` + RowStatus *RowStatus `json:"rowStatus"` // Domain specific fields Content *string `json:"content"` @@ -37,8 +38,11 @@ type MemoFind struct { ID *int `json:"id"` // Standard fields - CreatorID *int `json:"creatorId"` - RowStatus *string `json:"rowStatus"` + RowStatus *RowStatus `json:"rowStatus"` + CreatorID *int `json:"creatorId"` + + // Domain specific fields + Pinned *bool } type MemoDelete struct { diff --git a/api/memo_organizer.go b/api/memo_organizer.go new file mode 100644 index 00000000..4897a99f --- /dev/null +++ b/api/memo_organizer.go @@ -0,0 +1,21 @@ +package api + +type MemoOrganizer struct { + ID int + + // Domain specific fields + MemoID int + UserID int + Pinned bool +} + +type MemoOrganizerFind struct { + MemoID int + UserID int +} + +type MemoOrganizerUpsert struct { + MemoID int + UserID int + Pinned bool `json:"pinned"` +} diff --git a/api/shortcut.go b/api/shortcut.go index 1e4d7eab..1f677cb2 100644 --- a/api/shortcut.go +++ b/api/shortcut.go @@ -4,10 +4,10 @@ type Shortcut struct { ID int `json:"id"` // Standard fields - CreatorID int - CreatedTs int64 `json:"createdTs"` - UpdatedTs int64 `json:"updatedTs"` - RowStatus string `json:"rowStatus"` + RowStatus RowStatus `json:"rowStatus"` + CreatorID int `json:"creatorId"` + CreatedTs int64 `json:"createdTs"` + UpdatedTs int64 `json:"updatedTs"` // Domain specific fields Title string `json:"title"` @@ -27,7 +27,7 @@ type ShortcutPatch struct { ID int // Standard fields - RowStatus *string `json:"rowStatus"` + RowStatus *RowStatus `json:"rowStatus"` // Domain specific fields Title *string `json:"title"` diff --git a/api/user.go b/api/user.go index bb867272..ecf521b8 100644 --- a/api/user.go +++ b/api/user.go @@ -10,12 +10,23 @@ const ( NormalUser Role = "USER" ) +func (e Role) String() string { + switch e { + case Owner: + return "OWNER" + case NormalUser: + return "USER" + } + return "USER" +} + type User struct { ID int `json:"id"` // Standard fields - CreatedTs int64 `json:"createdTs"` - UpdatedTs int64 `json:"updatedTs"` + RowStatus RowStatus `json:"rowStatus"` + CreatedTs int64 `json:"createdTs"` + UpdatedTs int64 `json:"updatedTs"` // Domain specific fields Email string `json:"email"` @@ -38,6 +49,9 @@ type UserCreate struct { type UserPatch struct { ID int + // Standard fields + RowStatus *RowStatus `json:"rowStatus"` + // Domain specific fields Email *string `json:"email"` Name *string `json:"name"` @@ -50,6 +64,9 @@ type UserPatch struct { type UserFind struct { ID *int `json:"id"` + // Standard fields + RowStatus *RowStatus `json:"rowStatus"` + // Domain specific fields Email *string `json:"email"` Role *Role diff --git a/server/auth.go b/server/auth.go index 928540e0..60dbfd56 100644 --- a/server/auth.go +++ b/server/auth.go @@ -27,6 +27,8 @@ func (s *Server) registerAuthRoutes(g *echo.Group) { } if user == nil { return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("User not found with email %s", login.Email)) + } else if user.RowStatus == api.Archived { + return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", login.Email)) } // Compare the stored hashed password, with the hashed version of the password that was received. diff --git a/server/basic_auth.go b/server/basic_auth.go index ca57ffac..64841a98 100644 --- a/server/basic_auth.go +++ b/server/basic_auth.go @@ -85,6 +85,8 @@ func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc { } if user == nil { return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userID)) + } else if user.RowStatus == api.Archived { + return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email)) } // Stores userID into context. diff --git a/server/memo.go b/server/memo.go index b8f6a9eb..649ff72c 100644 --- a/server/memo.go +++ b/server/memo.go @@ -65,10 +65,16 @@ func (s *Server) registerMemoRoutes(g *echo.Group) { memoFind := &api.MemoFind{ CreatorID: &userID, } - rowStatus := c.QueryParam("rowStatus") + + rowStatus := api.RowStatus(c.QueryParam("rowStatus")) if rowStatus != "" { memoFind.RowStatus = &rowStatus } + pinnedStr := c.QueryParam("pinned") + if pinnedStr != "" { + pinned := pinnedStr == "true" + memoFind.Pinned = &pinned + } list, err := s.Store.FindMemoList(memoFind) if err != nil { @@ -83,6 +89,45 @@ func (s *Server) registerMemoRoutes(g *echo.Group) { return nil }) + g.POST("/memo/:memoId/organizer", func(c echo.Context) error { + memoID, err := strconv.Atoi(c.Param("memoId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) + } + + userID := c.Get(getUserIDContextKey()).(int) + memoOrganizerUpsert := &api.MemoOrganizerUpsert{ + MemoID: memoID, + UserID: userID, + } + if err := json.NewDecoder(c.Request().Body).Decode(memoOrganizerUpsert); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err) + } + + err = s.Store.UpsertMemoOrganizer(memoOrganizerUpsert) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err) + } + + memo, err := s.Store.FindMemo(&api.MemoFind{ + ID: &memoID, + }) + if err != nil { + if common.ErrorCode(err) == common.NotFound { + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err) + } + + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) + } + + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) + if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(memo)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo response").SetInternal(err) + } + + return nil + }) + g.GET("/memo/:memoId", func(c echo.Context) error { memoID, err := strconv.Atoi(c.Param("memoId")) if err != nil { diff --git a/server/user.go b/server/user.go index 354318e4..3bacf4e7 100644 --- a/server/user.go +++ b/server/user.go @@ -6,6 +6,7 @@ import ( "memos/api" "memos/common" "net/http" + "strconv" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" @@ -84,19 +85,6 @@ func (s *Server) registerUserRoutes(g *echo.Group) { return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err) } - if userPatch.Email != nil { - userFind := api.UserFind{ - Email: userPatch.Email, - } - user, err := s.Store.FindUser(&userFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", *userPatch.Email)).SetInternal(err) - } - if user != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("User with email %s existed", *userPatch.Email)).SetInternal(err) - } - } - if userPatch.Password != nil && *userPatch.Password != "" { passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost) if err != nil { @@ -124,4 +112,53 @@ func (s *Server) registerUserRoutes(g *echo.Group) { return nil }) + + g.PATCH("/user/:userId", func(c echo.Context) error { + currentUserID := c.Get(getUserIDContextKey()).(int) + currentUser, err := s.Store.FindUser(&api.UserFind{ + ID: ¤tUserID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + if currentUser == nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err) + } else if currentUser.Role != api.Owner { + return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err) + } + + userID, err := strconv.Atoi(c.Param("userId")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("userId"))).SetInternal(err) + } + + userPatch := &api.UserPatch{ + ID: userID, + } + if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err) + } + + if userPatch.Password != nil && *userPatch.Password != "" { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) + } + + passwordHashStr := string(passwordHash) + userPatch.PasswordHash = &passwordHashStr + } + + user, err := s.Store.PatchUser(userPatch) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err) + } + + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) + if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err) + } + + return nil + }) } diff --git a/server/webhook.go b/server/webhook.go index 0f7662c9..6f0f6d86 100644 --- a/server/webhook.go +++ b/server/webhook.go @@ -67,7 +67,7 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) { memoFind := &api.MemoFind{ CreatorID: &user.ID, } - rowStatus := c.QueryParam("rowStatus") + rowStatus := api.RowStatus(c.QueryParam("rowStatus")) if rowStatus != "" { memoFind.RowStatus = &rowStatus } diff --git a/store/memo.go b/store/memo.go index 545b3383..06f65140 100644 --- a/store/memo.go +++ b/store/memo.go @@ -7,8 +7,45 @@ import ( "strings" ) +// memoRaw is the store model for an Memo. +// Fields have exactly the same meanings as Memo. +type memoRaw struct { + ID int + + // Standard fields + RowStatus api.RowStatus + CreatorID int + CreatedTs int64 + UpdatedTs int64 + + // Domain specific fields + Content string +} + +// toMemo creates an instance of Memo based on the memoRaw. +// This is intended to be called when we need to compose an Memo relationship. +func (raw *memoRaw) toMemo() *api.Memo { + return &api.Memo{ + ID: raw.ID, + + // Standard fields + RowStatus: raw.RowStatus, + CreatorID: raw.CreatorID, + CreatedTs: raw.CreatedTs, + UpdatedTs: raw.UpdatedTs, + + // Domain specific fields + Content: raw.Content, + } +} + func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) { - memo, err := createMemo(s.db, create) + memoRaw, err := createMemoRaw(s.db, create) + if err != nil { + return nil, err + } + + memo, err := s.composeMemo(memoRaw) if err != nil { return nil, err } @@ -17,7 +54,12 @@ func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) { } func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) { - memo, err := patchMemo(s.db, patch) + memoRaw, err := patchMemoRaw(s.db, patch) + if err != nil { + return nil, err + } + + memo, err := s.composeMemo(memoRaw) if err != nil { return nil, err } @@ -26,16 +68,26 @@ func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) { } func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) { - list, err := findMemoList(s.db, find) + memoRawList, err := findMemoRawList(s.db, find) if err != nil { return nil, err } + list := []*api.Memo{} + for _, raw := range memoRawList { + memo, err := s.composeMemo(raw) + if err != nil { + return nil, err + } + + list = append(list, memo) + } + return list, nil } func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) { - list, err := findMemoList(s.db, find) + list, err := findMemoRawList(s.db, find) if err != nil { return nil, err } @@ -44,7 +96,12 @@ func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) { return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} } - return list[0], nil + memo, err := s.composeMemo(list[0]) + if err != nil { + return nil, err + } + + return memo, nil } func (s *Store) DeleteMemo(delete *api.MemoDelete) error { @@ -56,7 +113,7 @@ func (s *Store) DeleteMemo(delete *api.MemoDelete) error { return nil } -func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) { +func createMemoRaw(db *DB, create *api.MemoCreate) (*memoRaw, error) { set := []string{"creator_id", "content"} placeholder := []string{"?", "?"} args := []interface{}{create.CreatorID, create.Content} @@ -79,26 +136,23 @@ func createMemo(db *DB, create *api.MemoCreate) (*api.Memo, error) { } defer row.Close() - if !row.Next() { - return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} - } - - var memo api.Memo + row.Next() + var memoRaw memoRaw if err := row.Scan( - &memo.ID, - &memo.CreatorID, - &memo.CreatedTs, - &memo.UpdatedTs, - &memo.Content, - &memo.RowStatus, + &memoRaw.ID, + &memoRaw.CreatorID, + &memoRaw.CreatedTs, + &memoRaw.UpdatedTs, + &memoRaw.Content, + &memoRaw.RowStatus, ); err != nil { return nil, FormatError(err) } - return &memo, nil + return &memoRaw, nil } -func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) { +func patchMemoRaw(db *DB, patch *api.MemoPatch) (*memoRaw, error) { set, args := []string{}, []interface{}{} if v := patch.Content; v != nil { @@ -125,21 +179,21 @@ func patchMemo(db *DB, patch *api.MemoPatch) (*api.Memo, error) { return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} } - var memo api.Memo + var memoRaw memoRaw if err := row.Scan( - &memo.ID, - &memo.CreatedTs, - &memo.UpdatedTs, - &memo.Content, - &memo.RowStatus, + &memoRaw.ID, + &memoRaw.CreatedTs, + &memoRaw.UpdatedTs, + &memoRaw.Content, + &memoRaw.RowStatus, ); err != nil { return nil, FormatError(err) } - return &memo, nil + return &memoRaw, nil } -func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) { +func findMemoRawList(db *DB, find *api.MemoFind) ([]*memoRaw, error) { where, args := []string{"1 = 1"}, []interface{}{} if v := find.ID; v != nil { @@ -151,6 +205,9 @@ func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) { if v := find.RowStatus; v != nil { where, args = append(where, "row_status = ?"), append(args, *v) } + if v := find.Pinned; v != nil { + where = append(where, "id in (SELECT memo_id FROM memo_organizer WHERE pinned = 1 AND user_id = memo.creator_id )") + } rows, err := db.Db.Query(` SELECT @@ -169,28 +226,28 @@ func findMemoList(db *DB, find *api.MemoFind) ([]*api.Memo, error) { } defer rows.Close() - list := make([]*api.Memo, 0) + memoRawList := make([]*memoRaw, 0) for rows.Next() { - var memo api.Memo + var memoRaw memoRaw if err := rows.Scan( - &memo.ID, - &memo.CreatorID, - &memo.CreatedTs, - &memo.UpdatedTs, - &memo.Content, - &memo.RowStatus, + &memoRaw.ID, + &memoRaw.CreatorID, + &memoRaw.CreatedTs, + &memoRaw.UpdatedTs, + &memoRaw.Content, + &memoRaw.RowStatus, ); err != nil { return nil, FormatError(err) } - list = append(list, &memo) + memoRawList = append(memoRawList, &memoRaw) } if err := rows.Err(); err != nil { return nil, FormatError(err) } - return list, nil + return memoRawList, nil } func deleteMemo(db *DB, delete *api.MemoDelete) error { @@ -206,3 +263,19 @@ func deleteMemo(db *DB, delete *api.MemoDelete) error { return nil } + +func (s *Store) composeMemo(raw *memoRaw) (*api.Memo, error) { + memo := raw.toMemo() + + memoOrganizer, err := s.FindMemoOrganizer(&api.MemoOrganizerFind{ + MemoID: memo.ID, + UserID: memo.CreatorID, + }) + if err != nil && common.ErrorCode(err) != common.NotFound { + return nil, err + } else if memoOrganizer != nil { + memo.Pinned = memoOrganizer.Pinned + } + + return memo, nil +} diff --git a/store/memo_organizer.go b/store/memo_organizer.go new file mode 100644 index 00000000..82d4b538 --- /dev/null +++ b/store/memo_organizer.go @@ -0,0 +1,117 @@ +package store + +import ( + "fmt" + "memos/api" + "memos/common" +) + +// memoOrganizerRaw is the store model for an MemoOrganizer. +// Fields have exactly the same meanings as MemoOrganizer. +type memoOrganizerRaw struct { + ID int + + // Domain specific fields + MemoID int + UserID int + Pinned bool +} + +func (raw *memoOrganizerRaw) toMemoOrganizer() *api.MemoOrganizer { + return &api.MemoOrganizer{ + ID: raw.ID, + + MemoID: raw.MemoID, + UserID: raw.UserID, + Pinned: raw.Pinned, + } +} + +func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) { + memoOrganizerRaw, err := findMemoOrganizer(s.db, find) + if err != nil { + return nil, err + } + + memoOrganizer := memoOrganizerRaw.toMemoOrganizer() + + return memoOrganizer, nil +} + +func (s *Store) UpsertMemoOrganizer(upsert *api.MemoOrganizerUpsert) error { + err := upsertMemoOrganizer(s.db, upsert) + if err != nil { + return err + } + + return nil +} + +func findMemoOrganizer(db *DB, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) { + row, err := db.Db.Query(` + SELECT + id, + memo_id, + user_id, + pinned + FROM memo_organizer + WHERE memo_id = ? AND user_id = ? + `, find.MemoID, find.UserID) + if err != nil { + return nil, FormatError(err) + } + defer row.Close() + + if !row.Next() { + return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} + } + + var memoOrganizerRaw memoOrganizerRaw + if err := row.Scan( + &memoOrganizerRaw.ID, + &memoOrganizerRaw.MemoID, + &memoOrganizerRaw.UserID, + &memoOrganizerRaw.Pinned, + ); err != nil { + return nil, FormatError(err) + } + + return &memoOrganizerRaw, nil +} + +func upsertMemoOrganizer(db *DB, upsert *api.MemoOrganizerUpsert) error { + row, err := db.Db.Query(` + INSERT INTO memo_organizer ( + memo_id, + user_id, + pinned + ) + VALUES (?, ?, ?) + ON CONFLICT(memo_id, user_id) DO UPDATE + SET + pinned = EXCLUDED.pinned + RETURNING id, memo_id, user_id, pinned + `, + upsert.MemoID, + upsert.UserID, + upsert.Pinned, + ) + if err != nil { + return FormatError(err) + } + + defer row.Close() + + row.Next() + var memoOrganizer api.MemoOrganizer + if err := row.Scan( + &memoOrganizer.ID, + &memoOrganizer.MemoID, + &memoOrganizer.UserID, + &memoOrganizer.Pinned, + ); err != nil { + return FormatError(err) + } + + return nil +} diff --git a/store/migration/10000__reset.sql b/store/migration/10000__reset.sql index d5eb25b2..83b86f3c 100644 --- a/store/migration/10000__reset.sql +++ b/store/migration/10000__reset.sql @@ -1,3 +1,4 @@ +DROP TABLE IF EXISTS `memo_organizer`; DROP TABLE IF EXISTS `memo`; DROP TABLE IF EXISTS `shortcut`; DROP TABLE IF EXISTS `resource`; diff --git a/store/migration/10001__schema.sql b/store/migration/10001__schema.sql index e7f9ddd7..fde241c9 100644 --- a/store/migration/10001__schema.sql +++ b/store/migration/10001__schema.sql @@ -3,6 +3,8 @@ CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), + -- allowed row status are 'NORMAL', 'ARCHIVED'. + row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', email TEXT NOT NULL UNIQUE, role TEXT NOT NULL CHECK (role IN ('OWNER', 'USER')) DEFAULT 'USER', name TEXT NOT NULL, @@ -33,10 +35,9 @@ CREATE TABLE memo ( creator_id INTEGER NOT NULL, created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), - -- allowed row status are 'NORMAL', 'ARCHIVED', 'HIDDEN'. - row_status TEXT NOT NULL DEFAULT 'NORMAL', + row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', content TEXT NOT NULL DEFAULT '', - FOREIGN KEY(creator_id) REFERENCES users(id) + FOREIGN KEY(creator_id) REFERENCES user(id) ); INSERT INTO @@ -56,17 +57,32 @@ WHERE rowid = old.rowid; END; +-- memo_organizer +CREATE TABLE memo_organizer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memo_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0, + FOREIGN KEY(memo_id) REFERENCES memo(id), + FOREIGN KEY(user_id) REFERENCES user(id), + UNIQUE(memo_id, user_id) +); + +INSERT INTO + sqlite_sequence (name, seq) +VALUES + ('memo_organizer', 100); + -- shortcut CREATE TABLE shortcut ( id INTEGER PRIMARY KEY AUTOINCREMENT, creator_id INTEGER NOT NULL, created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), + row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', title TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '{}', - -- allowed row status are 'NORMAL', 'ARCHIVED'. - row_status TEXT NOT NULL DEFAULT 'NORMAL', - FOREIGN KEY(creator_id) REFERENCES users(id) + FOREIGN KEY(creator_id) REFERENCES user(id) ); INSERT INTO @@ -96,7 +112,7 @@ CREATE TABLE resource ( blob BLOB NOT NULL, type TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(creator_id) REFERENCES users(id) + FOREIGN KEY(creator_id) REFERENCES user(id) ); INSERT INTO diff --git a/store/resource.go b/store/resource.go index 63418baf..e46c0e8c 100644 --- a/store/resource.go +++ b/store/resource.go @@ -7,22 +7,63 @@ import ( "strings" ) +// resourceRaw is the store model for an Resource. +// Fields have exactly the same meanings as Resource. +type resourceRaw struct { + ID int + + // Standard fields + CreatorID int + CreatedTs int64 + UpdatedTs int64 + + // Domain specific fields + Filename string + Blob []byte + Type string + Size int64 +} + +func (raw *resourceRaw) toResource() *api.Resource { + return &api.Resource{ + ID: raw.ID, + + // Standard fields + CreatorID: raw.CreatorID, + CreatedTs: raw.CreatedTs, + UpdatedTs: raw.UpdatedTs, + + // Domain specific fields + Filename: raw.Filename, + Blob: raw.Blob, + Type: raw.Type, + Size: raw.Size, + } +} + func (s *Store) CreateResource(create *api.ResourceCreate) (*api.Resource, error) { - resource, err := createResource(s.db, create) + resourceRaw, err := createResource(s.db, create) if err != nil { return nil, err } + resource := resourceRaw.toResource() + return resource, nil } func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) { - list, err := findResourceList(s.db, find) + resourceRawList, err := findResourceList(s.db, find) if err != nil { return nil, err } - return list, nil + resourceList := []*api.Resource{} + for _, raw := range resourceRawList { + resourceList = append(resourceList, raw.toResource()) + } + + return resourceList, nil } func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) { @@ -35,7 +76,9 @@ func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) { return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} } - return list[0], nil + resource := list[0].toResource() + + return resource, nil } func (s *Store) DeleteResource(delete *api.ResourceDelete) error { @@ -47,7 +90,7 @@ func (s *Store) DeleteResource(delete *api.ResourceDelete) error { return nil } -func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) { +func createResource(db *DB, create *api.ResourceCreate) (*resourceRaw, error) { row, err := db.Db.Query(` INSERT INTO resource ( filename, @@ -70,27 +113,24 @@ func createResource(db *DB, create *api.ResourceCreate) (*api.Resource, error) { } defer row.Close() - if !row.Next() { - return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} - } - - var resource api.Resource + row.Next() + var resourceRaw resourceRaw if err := row.Scan( - &resource.ID, - &resource.Filename, - &resource.Blob, - &resource.Type, - &resource.Size, - &resource.CreatedTs, - &resource.UpdatedTs, + &resourceRaw.ID, + &resourceRaw.Filename, + &resourceRaw.Blob, + &resourceRaw.Type, + &resourceRaw.Size, + &resourceRaw.CreatedTs, + &resourceRaw.UpdatedTs, ); err != nil { return nil, FormatError(err) } - return &resource, nil + return &resourceRaw, nil } -func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) { +func findResourceList(db *DB, find *api.ResourceFind) ([]*resourceRaw, error) { where, args := []string{"1 = 1"}, []interface{}{} if v := find.ID; v != nil { @@ -121,29 +161,29 @@ func findResourceList(db *DB, find *api.ResourceFind) ([]*api.Resource, error) { } defer rows.Close() - list := make([]*api.Resource, 0) + resourceRawList := make([]*resourceRaw, 0) for rows.Next() { - var resource api.Resource + var resourceRaw resourceRaw if err := rows.Scan( - &resource.ID, - &resource.Filename, - &resource.Blob, - &resource.Type, - &resource.Size, - &resource.CreatedTs, - &resource.UpdatedTs, + &resourceRaw.ID, + &resourceRaw.Filename, + &resourceRaw.Blob, + &resourceRaw.Type, + &resourceRaw.Size, + &resourceRaw.CreatedTs, + &resourceRaw.UpdatedTs, ); err != nil { return nil, FormatError(err) } - list = append(list, &resource) + resourceRawList = append(resourceRawList, &resourceRaw) } if err := rows.Err(); err != nil { return nil, FormatError(err) } - return list, nil + return resourceRawList, nil } func deleteResource(db *DB, delete *api.ResourceDelete) error { diff --git a/store/seed/10000__reset.sql b/store/seed/10000__reset.sql index e1672d3a..4b47589f 100644 --- a/store/seed/10000__reset.sql +++ b/store/seed/10000__reset.sql @@ -1,3 +1,4 @@ +DELETE FROM memo_organizer; DELETE FROM resource; DELETE FROM shortcut; DELETE FROM memo; diff --git a/store/seed/10001__user.sql b/store/seed/10001__user.sql index 3a3a5495..dd23f6b6 100644 --- a/store/seed/10001__user.sql +++ b/store/seed/10001__user.sql @@ -12,6 +12,6 @@ VALUES 'guest', 'guest@example.com', 'guest_open_id', - -- "secret" + -- raw password: secret '$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK' ); diff --git a/store/seed/10002__memo.sql b/store/seed/10002__memo.sql index f4f747f1..82828822 100644 --- a/store/seed/10002__memo.sql +++ b/store/seed/10002__memo.sql @@ -1,23 +1,25 @@ INSERT INTO memo ( + `id`, `content`, `creator_id` ) VALUES ( + 101, '#memos 👋 Welcome to memos', 101 ); INSERT INTO memo ( + `id`, `content`, - `creator_id`, - `row_status` + `creator_id` ) VALUES ( + 102, '好好学习,天天向上。', - 101, - 'ARCHIVED' + 101 ); diff --git a/store/seed/10003__memo_organizer.sql b/store/seed/10003__memo_organizer.sql new file mode 100644 index 00000000..0318518c --- /dev/null +++ b/store/seed/10003__memo_organizer.sql @@ -0,0 +1,12 @@ +INSERT INTO + memo_organizer ( + `memo_id`, + `user_id`, + `pinned` + ) +VALUES + ( + 102, + 101, + 1 + ); diff --git a/store/seed/10002__shortcut.sql b/store/seed/10004__shortcut.sql similarity index 100% rename from store/seed/10002__shortcut.sql rename to store/seed/10004__shortcut.sql diff --git a/store/shortcut.go b/store/shortcut.go index a542a3e8..68bf098c 100644 --- a/store/shortcut.go +++ b/store/shortcut.go @@ -7,30 +7,69 @@ import ( "strings" ) +// shortcutRaw is the store model for an Shortcut. +// Fields have exactly the same meanings as Shortcut. +type shortcutRaw struct { + ID int + + // Standard fields + RowStatus api.RowStatus + CreatorID int + CreatedTs int64 + UpdatedTs int64 + + // Domain specific fields + Title string + Payload string +} + +func (raw *shortcutRaw) toShortcut() *api.Shortcut { + return &api.Shortcut{ + ID: raw.ID, + + RowStatus: raw.RowStatus, + CreatorID: raw.CreatorID, + CreatedTs: raw.CreatedTs, + UpdatedTs: raw.UpdatedTs, + + Title: raw.Title, + Payload: raw.Payload, + } +} + func (s *Store) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) { - shortcut, err := createShortcut(s.db, create) + shortcutRaw, err := createShortcut(s.db, create) if err != nil { return nil, err } + shortcut := shortcutRaw.toShortcut() + return shortcut, nil } func (s *Store) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) { - shortcut, err := patchShortcut(s.db, patch) + shortcutRaw, err := patchShortcut(s.db, patch) if err != nil { return nil, err } + shortcut := shortcutRaw.toShortcut() + return shortcut, nil } func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) { - list, err := findShortcutList(s.db, find) + shortcutRawList, err := findShortcutList(s.db, find) if err != nil { return nil, err } + list := []*api.Shortcut{} + for _, raw := range shortcutRawList { + list = append(list, raw.toShortcut()) + } + return list, nil } @@ -44,7 +83,9 @@ func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) { return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} } - return list[0], nil + shortcut := list[0].toShortcut() + + return shortcut, nil } func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error { @@ -56,7 +97,7 @@ func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error { return nil } -func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) { +func createShortcut(db *DB, create *api.ShortcutCreate) (*shortcutRaw, error) { row, err := db.Db.Query(` INSERT INTO shortcut ( title, @@ -70,30 +111,29 @@ func createShortcut(db *DB, create *api.ShortcutCreate) (*api.Shortcut, error) { create.Payload, create.CreatorID, ) - if err != nil { return nil, FormatError(err) } defer row.Close() row.Next() - var shortcut api.Shortcut + var shortcutRaw shortcutRaw if err := row.Scan( - &shortcut.ID, - &shortcut.Title, - &shortcut.Payload, - &shortcut.CreatorID, - &shortcut.CreatedTs, - &shortcut.UpdatedTs, - &shortcut.RowStatus, + &shortcutRaw.ID, + &shortcutRaw.Title, + &shortcutRaw.Payload, + &shortcutRaw.CreatorID, + &shortcutRaw.CreatedTs, + &shortcutRaw.UpdatedTs, + &shortcutRaw.RowStatus, ); err != nil { return nil, FormatError(err) } - return &shortcut, nil + return &shortcutRaw, nil } -func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) { +func patchShortcut(db *DB, patch *api.ShortcutPatch) (*shortcutRaw, error) { set, args := []string{}, []interface{}{} if v := patch.Title; v != nil { @@ -123,22 +163,22 @@ func patchShortcut(db *DB, patch *api.ShortcutPatch) (*api.Shortcut, error) { return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")} } - var shortcut api.Shortcut + var shortcutRaw shortcutRaw if err := row.Scan( - &shortcut.ID, - &shortcut.Title, - &shortcut.Payload, - &shortcut.CreatedTs, - &shortcut.UpdatedTs, - &shortcut.RowStatus, + &shortcutRaw.ID, + &shortcutRaw.Title, + &shortcutRaw.Payload, + &shortcutRaw.CreatedTs, + &shortcutRaw.UpdatedTs, + &shortcutRaw.RowStatus, ); err != nil { return nil, FormatError(err) } - return &shortcut, nil + return &shortcutRaw, nil } -func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) { +func findShortcutList(db *DB, find *api.ShortcutFind) ([]*shortcutRaw, error) { where, args := []string{"1 = 1"}, []interface{}{} if v := find.ID; v != nil { @@ -169,29 +209,29 @@ func findShortcutList(db *DB, find *api.ShortcutFind) ([]*api.Shortcut, error) { } defer rows.Close() - list := make([]*api.Shortcut, 0) + shortcutRawList := make([]*shortcutRaw, 0) for rows.Next() { - var shortcut api.Shortcut + var shortcutRaw shortcutRaw if err := rows.Scan( - &shortcut.ID, - &shortcut.Title, - &shortcut.Payload, - &shortcut.CreatorID, - &shortcut.CreatedTs, - &shortcut.UpdatedTs, - &shortcut.RowStatus, + &shortcutRaw.ID, + &shortcutRaw.Title, + &shortcutRaw.Payload, + &shortcutRaw.CreatorID, + &shortcutRaw.CreatedTs, + &shortcutRaw.UpdatedTs, + &shortcutRaw.RowStatus, ); err != nil { return nil, FormatError(err) } - list = append(list, &shortcut) + shortcutRawList = append(shortcutRawList, &shortcutRaw) } if err := rows.Err(); err != nil { return nil, FormatError(err) } - return list, nil + return shortcutRawList, nil } func deleteShortcut(db *DB, delete *api.ShortcutDelete) error { diff --git a/store/user.go b/store/user.go index 5b9834f8..d45f4d90 100644 --- a/store/user.go +++ b/store/user.go @@ -7,30 +7,73 @@ import ( "strings" ) +// userRaw is the store model for an User. +// Fields have exactly the same meanings as User. +type userRaw struct { + ID int + + // Standard fields + RowStatus api.RowStatus + CreatedTs int64 + UpdatedTs int64 + + // Domain specific fields + Email string + Role api.Role + Name string + PasswordHash string + OpenID string +} + +func (raw *userRaw) toUser() *api.User { + return &api.User{ + ID: raw.ID, + + RowStatus: raw.RowStatus, + CreatedTs: raw.CreatedTs, + UpdatedTs: raw.UpdatedTs, + + Email: raw.Email, + Role: raw.Role, + Name: raw.Name, + PasswordHash: raw.PasswordHash, + OpenID: raw.OpenID, + } +} + func (s *Store) CreateUser(create *api.UserCreate) (*api.User, error) { - user, err := createUser(s.db, create) + userRaw, err := createUser(s.db, create) if err != nil { return nil, err } + user := userRaw.toUser() + return user, nil } func (s *Store) PatchUser(patch *api.UserPatch) (*api.User, error) { - user, err := patchUser(s.db, patch) + userRaw, err := patchUser(s.db, patch) if err != nil { return nil, err } + user := userRaw.toUser() + return user, nil } func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) { - list, err := findUserList(s.db, find) + userRawList, err := findUserList(s.db, find) if err != nil { return nil, err } + list := []*api.User{} + for _, raw := range userRawList { + list = append(list, raw.toUser()) + } + return list, nil } @@ -46,10 +89,12 @@ func (s *Store) FindUser(find *api.UserFind) (*api.User, error) { return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1. ", len(list), find)} } - return list[0], nil + user := list[0].toUser() + + return user, nil } -func createUser(db *DB, create *api.UserCreate) (*api.User, error) { +func createUser(db *DB, create *api.UserCreate) (*userRaw, error) { row, err := db.Db.Query(` INSERT INTO user ( email, @@ -73,37 +118,40 @@ func createUser(db *DB, create *api.UserCreate) (*api.User, error) { defer row.Close() row.Next() - var user api.User + var userRaw userRaw if err := row.Scan( - &user.ID, - &user.Email, - &user.Role, - &user.Name, - &user.PasswordHash, - &user.OpenID, - &user.CreatedTs, - &user.UpdatedTs, + &userRaw.ID, + &userRaw.Email, + &userRaw.Role, + &userRaw.Name, + &userRaw.PasswordHash, + &userRaw.OpenID, + &userRaw.CreatedTs, + &userRaw.UpdatedTs, ); err != nil { return nil, FormatError(err) } - return &user, nil + return &userRaw, nil } -func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) { +func patchUser(db *DB, patch *api.UserPatch) (*userRaw, error) { set, args := []string{}, []interface{}{} + if v := patch.RowStatus; v != nil { + set, args = append(set, "row_status = ?"), append(args, *v) + } if v := patch.Email; v != nil { - set, args = append(set, "email = ?"), append(args, v) + set, args = append(set, "email = ?"), append(args, *v) } if v := patch.Name; v != nil { - set, args = append(set, "name = ?"), append(args, v) + set, args = append(set, "name = ?"), append(args, *v) } if v := patch.PasswordHash; v != nil { - set, args = append(set, "password_hash = ?"), append(args, v) + set, args = append(set, "password_hash = ?"), append(args, *v) } if v := patch.OpenID; v != nil { - set, args = append(set, "open_id = ?"), append(args, v) + set, args = append(set, "open_id = ?"), append(args, *v) } args = append(args, patch.ID) @@ -120,27 +168,27 @@ func patchUser(db *DB, patch *api.UserPatch) (*api.User, error) { defer row.Close() if row.Next() { - var user api.User + var userRaw userRaw if err := row.Scan( - &user.ID, - &user.Email, - &user.Role, - &user.Name, - &user.PasswordHash, - &user.OpenID, - &user.CreatedTs, - &user.UpdatedTs, + &userRaw.ID, + &userRaw.Email, + &userRaw.Role, + &userRaw.Name, + &userRaw.PasswordHash, + &userRaw.OpenID, + &userRaw.CreatedTs, + &userRaw.UpdatedTs, ); err != nil { return nil, FormatError(err) } - return &user, nil + return &userRaw, nil } return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.ID)} } -func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) { +func findUserList(db *DB, find *api.UserFind) ([]*userRaw, error) { where, args := []string{"1 = 1"}, []interface{}{} if v := find.ID; v != nil { @@ -178,29 +226,29 @@ func findUserList(db *DB, find *api.UserFind) ([]*api.User, error) { } defer rows.Close() - list := make([]*api.User, 0) + userRawList := make([]*userRaw, 0) for rows.Next() { - var user api.User + var userRaw userRaw if err := rows.Scan( - &user.ID, - &user.Email, - &user.Role, - &user.Name, - &user.PasswordHash, - &user.OpenID, - &user.CreatedTs, - &user.UpdatedTs, + &userRaw.ID, + &userRaw.Email, + &userRaw.Role, + &userRaw.Name, + &userRaw.PasswordHash, + &userRaw.OpenID, + &userRaw.CreatedTs, + &userRaw.UpdatedTs, ); err != nil { fmt.Println(err) return nil, FormatError(err) } - list = append(list, &user) + userRawList = append(userRawList, &userRaw) } if err := rows.Err(); err != nil { return nil, FormatError(err) } - return list, nil + return userRawList, nil }