diff --git a/go.mod b/go.mod index 298ea84..77ef993 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,12 @@ require ( github.com/lib/pq v1.10.9 go.etcd.io/bbolt v1.3.8 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.15.0 + golang.org/x/crypto v0.19.0 ) require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sys v0.14.0 // indirect + golang.org/x/sys v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index 42f3458..dc9d212 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= @@ -26,9 +26,9 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/dal/model.go b/internal/dal/model.go index b4c10dc..1cbd019 100644 --- a/internal/dal/model.go +++ b/internal/dal/model.go @@ -12,6 +12,15 @@ type ( UpdatedAt time.Time } + SessionFilter struct { + userID uint64 + limit int + name string + sortBy string + sortByDirection string + offset int + } + Session struct { ID uint64 Name string @@ -27,3 +36,82 @@ type ( UpdatedAt time.Time } ) + +func (s SessionFilter) UserID() uint64 { + return s.userID +} + +func (s SessionFilter) Limit() int { + return s.limit +} + +func (s SessionFilter) Name() string { + return s.name +} + +func (s SessionFilter) SortBy() string { + return s.sortBy +} + +func (s SessionFilter) SortByDirection() string { + return s.sortByDirection +} + +func (s SessionFilter) Offset() int { + return s.offset +} + +func WithName(name string) func(SessionFilter) SessionFilter { + return func(f SessionFilter) SessionFilter { + f.name = name + return f + } +} + +func WithSortByName(desc bool) func(SessionFilter) SessionFilter { + return func(f SessionFilter) SessionFilter { + f.sortBy = "name" + if desc { + f.sortByDirection = "DESC" + } else { + f.sortByDirection = "ASC" + } + return f + } +} + +func WithSortByUpdateAt(desc bool) func(SessionFilter) SessionFilter { + return func(f SessionFilter) SessionFilter { + f.sortBy = "updated_at" + if desc { + f.sortByDirection = "DESC" + } else { + f.sortByDirection = "ASC" + } + return f + } +} + +func WithOffset(offset int) func(SessionFilter) SessionFilter { + return func(f SessionFilter) SessionFilter { + f.offset = offset + return f + } +} + +func NewSessionFilter(userID uint64, limit int, opts ...func(SessionFilter) SessionFilter) SessionFilter { + f := SessionFilter{ + userID: userID, + limit: limit, + name: "", + sortBy: "updated_at", + sortByDirection: "ASC", + offset: 0, + } + + for _, opt := range opts { + f = opt(f) + } + + return f +} diff --git a/internal/dal/postgre/session.go b/internal/dal/postgre/session.go index a6c6f1d..baaf784 100644 --- a/internal/dal/postgre/session.go +++ b/internal/dal/postgre/session.go @@ -4,13 +4,16 @@ import ( "database/sql" "errors" "fmt" + "sync" "github.com/Roma7-7-7/shared-clipboard/internal/dal" ) -type SessionRepository struct { - db *sql.DB -} +type ( + SessionRepository struct { + db *sql.DB + } +) func NewSessionRepository(db *sql.DB) (*SessionRepository, error) { return &SessionRepository{ @@ -71,6 +74,75 @@ func (r *SessionRepository) GetAllByUserID(userID uint64) ([]*dal.Session, error return res, nil } +func (r *SessionRepository) FilterBy(filter dal.SessionFilter) ([]*dal.Session, int, error) { + var ( + totalCount = 0 + res = make([]*dal.Session, 0, 10) + filterQuery = "user_id = $1 AND ($2 = '' OR name LIKE $2)" + totalCountQuery = "SELECT COUNT(*) FROM sessions WHERE " + filterQuery + query = fmt.Sprintf("SELECT session_id, user_id, name, created_at, updated_at FROM sessions WHERE "+filterQuery+" ORDER BY %s %s OFFSET $3 LIMIT $4", filter.SortBy(), filter.SortByDirection()) + totalCountErr error + queryErr error + ) + + wg := sync.WaitGroup{} + wg.Add(2) + go func() { + defer wg.Done() + if err := r.db.QueryRow(totalCountQuery, filter.UserID(), "%"+filter.Name()+"%").Scan(&totalCount); err != nil { + if errors.Is(err, sql.ErrNoRows) { + totalCount = 0 + return + } + + totalCountErr = fmt.Errorf("get total count: %w", err) + } + }() + go func() { + defer wg.Done() + rows, err := r.db.Query(query, filter.UserID(), "%"+filter.Name()+"%", filter.Offset(), filter.Limit()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return + } + + queryErr = fmt.Errorf("get sessions: %w", err) + return + } + defer rows.Close() + + for rows.Next() { + var s dal.Session + + if err = rows.Scan( + &s.ID, + &s.UserID, + &s.Name, + &s.CreatedAt, + &s.UpdatedAt, + ); err != nil { + queryErr = fmt.Errorf("scan session: %w", err) + return + } + + res = append(res, &s) + } + }() + + wg.Wait() + if totalCountErr != nil && queryErr != nil { + return nil, 0, fmt.Errorf("filter sessions: %w; %w", totalCountErr, queryErr) + } + if totalCountErr != nil { + return nil, 0, fmt.Errorf("filter sessions: %w", totalCountErr) + } + if queryErr != nil { + return nil, 0, fmt.Errorf("filter sessions: %w", queryErr) + } + + return res, totalCount, nil +} + func (r *SessionRepository) Create(name string, userID uint64) (*dal.Session, error) { res := &dal.Session{ UserID: userID, @@ -115,6 +187,27 @@ func (r *SessionRepository) Update(id uint64, name string) (*dal.Session, error) return r.GetByID(id) } +func (r *SessionRepository) UpdateUpdatedAt(id uint64) error { + execRes, err := r.db.Exec("UPDATE sessions SET updated_at = now() WHERE session_id = $1", id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("session with session_id=%d not found: %w", id, dal.ErrNotFound) + } + + return fmt.Errorf("update session: %w", err) + } + + affected, err := execRes.RowsAffected() + if err != nil { + return fmt.Errorf("get affected rows: %w", err) + } + if affected == 0 { + return fmt.Errorf("session with session_id=%d not found: %w", id, dal.ErrNotFound) + } + + return nil +} + func (r *SessionRepository) Delete(id uint64) error { execRes, err := r.db.Exec("DELETE FROM sessions WHERE session_id = $1", id) if err != nil { diff --git a/internal/domain/session.go b/internal/domain/session.go index 3b26994..8e95fe8 100644 --- a/internal/domain/session.go +++ b/internal/domain/session.go @@ -17,6 +17,14 @@ var ( ) type ( + SessionFilter struct { + Limit int `validate:"required,gte=1,lte=100"` + Name string + SortBy string `validate:"omitempty,oneof=name updated_at"` + SortByDesc bool + Offset int `validate:"gte=0"` + } + Session struct { ID uint64 Name string @@ -28,8 +36,10 @@ type ( SessionRepository interface { GetByID(id uint64) (*dal.Session, error) GetAllByUserID(userID uint64) ([]*dal.Session, error) + FilterBy(dal.SessionFilter) ([]*dal.Session, int, error) Create(name string, userID uint64) (*dal.Session, error) Update(id uint64, name string) (*dal.Session, error) + UpdateUpdatedAt(id uint64) error Delete(id uint64) error } @@ -83,6 +93,35 @@ func (s *SessionService) GetByUserID(ctx context.Context, userID uint64) ([]*Ses return res, nil } +func (s *SessionService) FilterBy(ctx context.Context, userID uint64, filter SessionFilter) ([]*Session, int, error) { + s.log.Debugw(ctx, "filter sessions", "userID", userID, "filter", filter) + + filterOpts := make([]func(dal.SessionFilter) dal.SessionFilter, 0, 3) + if filter.Name != "" { + filterOpts = append(filterOpts, dal.WithName(filter.Name)) + } + switch filter.SortBy { + case "name": + filterOpts = append(filterOpts, dal.WithSortByName(filter.SortByDesc)) + case "updated_at": + filterOpts = append(filterOpts, dal.WithSortByUpdateAt(filter.SortByDesc)) + } + filterOpts = append(filterOpts, dal.WithOffset(filter.Offset)) + dalFilter := dal.NewSessionFilter(userID, filter.Limit, filterOpts...) + + sessions, total, err := s.sessionRepo.FilterBy(dalFilter) + if err != nil { + return nil, 0, fmt.Errorf("filter sessions by %v: %w", filter, err) + } + + s.log.Debugw(ctx, "sessions found", "count", len(sessions), "total", total) + res := make([]*Session, 0, len(sessions)) + for _, session := range sessions { + res = append(res, toSession(session)) + } + return res, total, nil +} + func (s *SessionService) Create(ctx context.Context, userID uint64, name string) (*Session, error) { s.log.Debugw(ctx, "create session", "name", name, "userID", userID) @@ -128,6 +167,17 @@ func (s *SessionService) Update(ctx context.Context, userID, sessionID uint64, n return toSession(updated), nil } +func (s *SessionService) UpdateUpdatedAt(ctx context.Context, sessionID uint64) error { + s.log.Debugw(ctx, "update session updated_at", "sessionID", sessionID) + + if err := s.sessionRepo.UpdateUpdatedAt(sessionID); err != nil { + return fmt.Errorf("update session updated_at by id=%d: %w", sessionID, err) + } + + s.log.Debugw(ctx, "session updated_at updated", "sessionID", sessionID) + return nil +} + func (s *SessionService) Delete(ctx context.Context, userID, sessionID uint64) error { s.log.Debugw(ctx, "delete session", "sessionID", sessionID) diff --git a/internal/handle/respond.go b/internal/handle/respond.go index 282a830..0fdaf49 100644 --- a/internal/handle/respond.go +++ b/internal/handle/respond.go @@ -24,6 +24,11 @@ type genericErrorResponse struct { Details any `json:"details,omitempty"` } +type paginatedResponse struct { + Items any `json:"items"` + TotalItems int `json:"totalItems"` +} + type responder struct { log log.TracedLogger } @@ -46,7 +51,7 @@ func (r *responder) Send(ctx context.Context, rw http.ResponseWriter, status int } } if !contentTypeSet { - rw.Header().Set(ContentTypeJSON, ContentTypeJSON) + rw.Header().Set(ContentTypeHeader, ContentTypeJSON) } rw.WriteHeader(status) diff --git a/internal/handle/routes.go b/internal/handle/routes.go index 3475c01..7a0b066 100644 --- a/internal/handle/routes.go +++ b/internal/handle/routes.go @@ -57,7 +57,7 @@ func NewRouter(ctx context.Context, deps Dependencies, log log.TracedLogger) (*c sessionHandler := NewSessionHandler(deps.SessionService, deps.ClipboardRepository, resp, log) authorizedRouter.Post("/v1/sessions", sessionHandler.Create) - authorizedRouter.Get("/v1/sessions", sessionHandler.GetAllByUserID) + authorizedRouter.Get("/v1/sessions", sessionHandler.FilterBy) authorizedRouter.Get("/v1/sessions/{sessionID}", sessionHandler.GetByID) authorizedRouter.Put("/v1/sessions/{sessionID}", sessionHandler.Update) authorizedRouter.Delete("/v1/sessions/{sessionID}", sessionHandler.Delete) diff --git a/internal/handle/session.go b/internal/handle/session.go index df00f7b..a737e3f 100644 --- a/internal/handle/session.go +++ b/internal/handle/session.go @@ -31,9 +31,10 @@ type ( SessionService interface { GetByID(ctx context.Context, userID, id uint64) (*domain.Session, error) - GetByUserID(ctx context.Context, userID uint64) ([]*domain.Session, error) + FilterBy(ctx context.Context, userID uint64, filter domain.SessionFilter) ([]*domain.Session, int, error) Create(ctx context.Context, userID uint64, name string) (*domain.Session, error) Update(ctx context.Context, userID, sessionID uint64, name string) (*domain.Session, error) + UpdateUpdatedAt(ctx context.Context, sessionID uint64) error Delete(ctx context.Context, userID, sessionID uint64) error } @@ -105,7 +106,7 @@ func (h *SessionHandler) GetByID(rw http.ResponseWriter, r *http.Request) { }, toDTO(session)) } -func (h *SessionHandler) GetAllByUserID(rw http.ResponseWriter, r *http.Request) { +func (h *SessionHandler) FilterBy(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() ) @@ -118,9 +119,36 @@ func (h *SessionHandler) GetAllByUserID(rw http.ResponseWriter, r *http.Request) } h.log.Debugw(ctx, "Get all sessions by user", "userID", auth.UserID) - sessions, err := h.service.GetByUserID(ctx, auth.UserID) + limitStr := r.URL.Query().Get("limit") + if limitStr == "" { + limitStr = "100" + } + limit, err := strconv.Atoi(limitStr) + if err != nil { + h.log.Debugw(ctx, "failed to parse limit", "limit", limitStr, err) + h.resp.SendBadRequest(ctx, rw, "limit param must be a valid int value") + return + } + offsetStr := r.URL.Query().Get("offset") + if offsetStr == "" { + offsetStr = "0" + } + offset, err := strconv.Atoi(offsetStr) if err != nil { - h.log.Errorw(ctx, "failed to get sessions", "userID", auth.UserID, err) + h.log.Debugw(ctx, "failed to parse offset", "offset", offset, err) + h.resp.SendBadRequest(ctx, rw, "offset param must be a valid int value") + return + } + + sessions, total, err := h.service.FilterBy(ctx, auth.UserID, domain.SessionFilter{ + Name: r.URL.Query().Get("name"), + SortBy: r.URL.Query().Get("sortBy"), + SortByDesc: strings.EqualFold(r.URL.Query().Get("desc"), "true"), + Limit: limit, + Offset: offset, + }) + if err != nil { + h.log.Errorw(ctx, "failed to get sessions", err) h.resp.SendInternalServerError(ctx, rw) return } @@ -131,7 +159,10 @@ func (h *SessionHandler) GetAllByUserID(rw http.ResponseWriter, r *http.Request) res = append(res, toDTO(session)) } - h.resp.Send(ctx, rw, http.StatusOK, nil, res) + h.resp.Send(ctx, rw, http.StatusOK, nil, &paginatedResponse{ + Items: res, + TotalItems: total, + }) } func (h *SessionHandler) Create(rw http.ResponseWriter, r *http.Request) { @@ -370,6 +401,11 @@ func (h *SessionHandler) SetClipboard(rw http.ResponseWriter, r *http.Request) { h.resp.SendInternalServerError(ctx, rw) return } + go func() { + if err := h.service.UpdateUpdatedAt(ctx, sid); err != nil { + h.log.Errorw(ctx, "failed to update session updated_at", err) + } + }() h.log.Debugw(ctx, "Set content", "id", sessionID) rw.Header().Set(LastModifiedHeader, clipboard.UpdatedAt.UTC().Format(http.TimeFormat)) diff --git a/web/src/routes/SessionsRoute.jsx b/web/src/routes/SessionsRoute.jsx index d9e3343..5bb3a15 100644 --- a/web/src/routes/SessionsRoute.jsx +++ b/web/src/routes/SessionsRoute.jsx @@ -41,9 +41,9 @@ function SessionsTable({onSuccess, onError}) { const [items, setItems] = useState([]) function refresh() { - axios.get(apiBaseURL + '/v1/sessions', {withCredentials: true}) + axios.get(apiBaseURL + '/v1/sessions?sortBy=updated_at&desc=true', {withCredentials: true}) .then(response => { - setItems(response.data.map((session) => refresh()} />)); + setItems(response.data.items.map((session) => refresh()} />)); onSuccess() }).catch(error => { console.log("Error: ", error)