From f8410b894091cac8f2cd308acaa0c1063b1e5e0c Mon Sep 17 00:00:00 2001 From: Felix Gateru Date: Wed, 4 Mar 2026 14:37:35 +0300 Subject: [PATCH] SMQ-3338 - Add created at period filter to entities (#3339) Signed-off-by: Felix Gateru --- apidocs/openapi/channels.yaml | 22 +++++ apidocs/openapi/clients.yaml | 22 +++++ apidocs/openapi/domains.yaml | 22 +++++ apidocs/openapi/groups.yaml | 24 ++++++ apidocs/openapi/users.yaml | 22 +++++ channels/api/http/decode.go | 24 ++++++ channels/api/http/endpoint_test.go | 108 ++++++++++++++++++++++++- channels/channels.go | 2 + channels/postgres/channels.go | 71 ++++++++++------- channels/postgres/channels_test.go | 112 +++++++++++++++++++++++++- clients/api/http/decode.go | 24 ++++++ clients/api/http/endpoints_test.go | 124 +++++++++++++++++++++++++++-- clients/clients.go | 2 + clients/postgres/clients.go | 75 +++++++++-------- clients/postgres/clients_test.go | 102 +++++++++++++++++++++++- domains/api/http/decode.go | 50 +++++++++--- domains/api/http/endpoint_test.go | 91 +++++++++++++++++++-- domains/domains.go | 36 +++++---- domains/postgres/domains.go | 71 ++++++++++------- domains/postgres/domains_test.go | 91 +++++++++++++++++++++ groups/api/http/decode.go | 54 +++++++++---- groups/api/http/decode_test.go | 56 +++++++++++++ groups/api/http/endpoint_test.go | 92 ++++++++++++++++++++- groups/page.go | 43 +++++----- groups/postgres/groups.go | 70 +++++++++------- groups/postgres/groups_test.go | 112 +++++++++++++++++++++++++- pkg/sdk/sdk.go | 8 ++ pkg/sdk/users_test.go | 93 ++++++++++++++++++++++ users/api/endpoint_test.go | 121 ++++++++++++++++++++++++++++ users/api/endpoints.go | 28 ++++--- users/api/requests.go | 29 ++++--- users/api/users.go | 50 +++++++++--- users/postgres/users.go | 61 ++++++++------ users/postgres/users_test.go | 84 +++++++++++++++++++ users/users.go | 42 +++++----- 35 files changed, 1736 insertions(+), 302 deletions(-) diff --git a/apidocs/openapi/channels.yaml b/apidocs/openapi/channels.yaml index 7f64262fb..cf09b84db 100644 --- a/apidocs/openapi/channels.yaml +++ b/apidocs/openapi/channels.yaml @@ -102,6 +102,8 @@ paths: - $ref: "#/components/parameters/Client" - $ref: "#/components/parameters/Group" - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/CreatedFrom" + - $ref: "#/components/parameters/CreatedTo" responses: "200": $ref: "#/components/responses/ChannelPageRes" @@ -944,6 +946,26 @@ components: required: false example: bb7edb32-2eac-4aad-aebe-ed96fe073879 + CreatedFrom: + name: created_from + description: Filter channels created from this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-01-01T00:00:00Z" + + CreatedTo: + name: created_to + description: Filter channels created up to this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-12-31T23:59:59Z" + requestBodies: ChannelCreateReq: description: JSON-formatted document describing the new channel to be registered diff --git a/apidocs/openapi/clients.yaml b/apidocs/openapi/clients.yaml index f12df0d72..73b1de7e0 100644 --- a/apidocs/openapi/clients.yaml +++ b/apidocs/openapi/clients.yaml @@ -100,6 +100,8 @@ paths: - $ref: "#/components/parameters/ConnectionType" - $ref: "#/components/parameters/Group" - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/CreatedFrom" + - $ref: "#/components/parameters/CreatedTo" security: - bearerAuth: [] responses: @@ -1418,6 +1420,26 @@ components: minLength: 36 required: false example: bb7edb32-2eac-4aad-aebe-ed96fe073879 + + CreatedFrom: + name: created_from + description: Filter clients created from this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-01-01T00:00:00Z" + + CreatedTo: + name: created_to + description: Filter clients created up to this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-12-31T23:59:59Z" requestBodies: ClientCreateReq: diff --git a/apidocs/openapi/domains.yaml b/apidocs/openapi/domains.yaml index 828de45e9..816c126ce 100644 --- a/apidocs/openapi/domains.yaml +++ b/apidocs/openapi/domains.yaml @@ -84,6 +84,8 @@ paths: - $ref: "./schemas/roles.yaml#/components/parameters/RoleNameQuery" - $ref: "#/components/parameters/AccessType" - $ref: "#/components/parameters/OnlyTotal" + - $ref: "#/components/parameters/CreatedFrom" + - $ref: "#/components/parameters/CreatedTo" tags: - Domains security: @@ -1291,6 +1293,26 @@ components: default: false required: false + CreatedFrom: + name: created_from + description: Filter domains created from this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-01-01T00:00:00Z" + + CreatedTo: + name: created_to + description: Filter domains created up to this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-12-31T23:59:59Z" + requestBodies: DomainCreateReq: description: JSON-formatted document describing the new domain to be registered diff --git a/apidocs/openapi/groups.yaml b/apidocs/openapi/groups.yaml index 7fb643bdc..157f661cf 100644 --- a/apidocs/openapi/groups.yaml +++ b/apidocs/openapi/groups.yaml @@ -103,6 +103,8 @@ paths: - $ref: "./schemas/roles.yaml#/components/parameters/RoleNameQuery" - $ref: "#/components/parameters/AccessType" - $ref: "#/components/parameters/OnlyTotal" + - $ref: "#/components/parameters/CreatedFrom" + - $ref: "#/components/parameters/CreatedTo" responses: "200": $ref: "#/components/responses/GroupPageRes" @@ -329,6 +331,8 @@ paths: - $ref: "#/components/parameters/Level" - $ref: "#/components/parameters/Tree" - $ref: "#/components/parameters/Direction" + - $ref: "#/components/parameters/CreatedFrom" + - $ref: "#/components/parameters/CreatedTo" responses: "200": $ref: "#/components/responses/GroupsHierarchyPageRes" @@ -1599,6 +1603,26 @@ components: default: false required: false + CreatedFrom: + name: created_from + description: Filter groups created from this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-01-01T00:00:00Z" + + CreatedTo: + name: created_to + description: Filter groups created up to this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-12-31T23:59:59Z" + User: name: user description: If provided lists groups associated with a user with the provided ID. Only available for admin users. diff --git a/apidocs/openapi/users.yaml b/apidocs/openapi/users.yaml index 39b73f94b..d35c84763 100644 --- a/apidocs/openapi/users.yaml +++ b/apidocs/openapi/users.yaml @@ -85,6 +85,8 @@ paths: - $ref: "#/components/parameters/Email" - $ref: "#/components/parameters/Tags" - $ref: "#/components/parameters/OnlyTotal" + - $ref: "#/components/parameters/CreatedFrom" + - $ref: "#/components/parameters/CreatedTo" security: - bearerAuth: [] responses: @@ -1403,6 +1405,26 @@ components: default: false required: false + CreatedFrom: + name: created_from + description: Filter users created from this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-01-01T00:00:00Z" + + CreatedTo: + name: created_to + description: Filter users created up to this date (inclusive). + in: query + schema: + type: string + format: date-time + required: false + example: "2023-12-31T23:59:59Z" + VerificationToken: name: token description: Verification token. diff --git a/channels/api/http/decode.go b/channels/api/http/decode.go index a64172d9d..4e880fb71 100644 --- a/channels/api/http/decode.go +++ b/channels/api/http/decode.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "strings" + "time" api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" @@ -158,6 +159,27 @@ func decodeListChannels(_ context.Context, r *http.Request) (any, error) { return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err) } + cfrom, err := apiutil.ReadStringQuery(r, "created_from", "") + if err != nil { + return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err) + } + cto, err := apiutil.ReadStringQuery(r, "created_to", "") + if err != nil { + return listChannelsReq{}, errors.Wrap(apiutil.ErrValidation, err) + } + + var createdFrom, createdTo time.Time + if cfrom != "" { + if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil { + return listChannelsReq{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + if cto != "" { + if createdTo, err = time.Parse(time.RFC3339, cto); err != nil { + return listChannelsReq{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + req := listChannelsReq{ Page: channels.Page{ Name: name, @@ -177,6 +199,8 @@ func decodeListChannels(_ context.Context, r *http.Request) (any, error) { ConnectionType: connectionType, ID: id, OnlyTotal: ot, + CreatedFrom: createdFrom, + CreatedTo: createdTo, }, userID: userID, } diff --git a/channels/api/http/endpoint_test.go b/channels/api/http/endpoint_test.go index 3d5037b16..bc867e12f 100644 --- a/channels/api/http/endpoint_test.go +++ b/channels/api/http/endpoint_test.go @@ -47,10 +47,11 @@ var ( UpdatedBy: testsutil.GenerateUUID(&testing.T{}), Status: channels.EnabledStatus, } - validID = testsutil.GenerateUUID(&testing.T{}) - validToken = "validToken" - invalidToken = "invalidToken" - contentType = "application/json" + validID = testsutil.GenerateUUID(&testing.T{}) + validToken = "validToken" + invalidToken = "invalidToken" + contentType = "application/json" + validTimeStamp = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) ) func newChannelsServer() (*httptest.Server, *mocks.Service, *authnmocks.Authentication) { @@ -893,6 +894,105 @@ func TestListChannels(t *testing.T) { status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, + { + desc: "list channels with created_from", + domainID: validID, + token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedFrom: validTimeStamp, + }, + listChannelsResponse: channels.ChannelsPage{ + Page: channels.Page{ + Total: 1, + }, + Channels: []channels.Channel{validChannelResp}, + }, + query: "created_from=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list channels with created_to", + domainID: validID, + token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedTo: validTimeStamp, + }, + listChannelsResponse: channels.ChannelsPage{ + Page: channels.Page{ + Total: 1, + }, + Channels: []channels.Channel{validChannelResp}, + }, + query: "created_to=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list channels with both created_from and created_to", + domainID: validID, + token: validToken, + pageMeta: channels.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedFrom: validTimeStamp, + CreatedTo: validTimeStamp, + }, + listChannelsResponse: channels.ChannelsPage{ + Page: channels.Page{ + Total: 1, + }, + Channels: []channels.Channel{validChannelResp}, + }, + query: "created_from=2024-01-01T00:00:00Z&created_to=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list channels with invalid created_from", + domainID: validID, + token: validToken, + query: "created_from=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list channels with duplicate created_from", + domainID: validID, + token: validToken, + query: "created_from=2024-01-01T00:00:00Z&created_from=2024-01-02T00:00:00Z", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list channels with invalid created_to", + domainID: validID, + token: validToken, + query: "created_to=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list channels with duplicate created_to", + domainID: validID, + token: validToken, + query: "created_to=2024-12-31T23:59:59Z&created_to=2024-12-30T23:59:59Z", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, } for _, tc := range cases { diff --git a/channels/channels.go b/channels/channels.go index 4c8816e58..bef162608 100644 --- a/channels/channels.go +++ b/channels/channels.go @@ -99,6 +99,8 @@ type Page struct { Actions []string `json:"actions,omitempty"` AccessType string `json:"access_type,omitempty"` IDs []string `json:"-"` + CreatedFrom time.Time `json:"created_from,omitempty"` + CreatedTo time.Time `json:"created_to,omitempty"` } // ChannelsPage contains page related metadata as well as list of channels that diff --git a/channels/postgres/channels.go b/channels/postgres/channels.go index 0311e83f3..3d609d6ad 100644 --- a/channels/postgres/channels.go +++ b/channels/postgres/channels.go @@ -1342,6 +1342,13 @@ func PageQuery(pm channels.Page) (string, error) { query = append(query, "c.metadata @> :metadata") } + if !pm.CreatedFrom.IsZero() { + query = append(query, "c.created_at >= :created_from") + } + if !pm.CreatedTo.IsZero() { + query = append(query, "c.created_at <= :created_to") + } + var emq string if len(query) > 0 { emq = fmt.Sprintf("WHERE %s", strings.Join(query, " AND ")) @@ -1393,40 +1400,44 @@ func toDBChannelsPage(pm channels.Page) (dbChannelsPage, error) { } return dbChannelsPage{ - Limit: pm.Limit, - Offset: pm.Offset, - Name: pm.Name, - Id: pm.ID, - Domain: pm.Domain, - Metadata: data, - Tags: tags, - Status: pm.Status, - GroupID: sql.NullString{Valid: pm.Group.Valid, String: pm.Group.Value}, - ClientID: pm.Client, - ConnType: connType, - RoleName: pm.RoleName, - RoleID: pm.RoleID, - Actions: pm.Actions, - AccessType: pm.AccessType, + Limit: pm.Limit, + Offset: pm.Offset, + Name: pm.Name, + Id: pm.ID, + Domain: pm.Domain, + Metadata: data, + Tags: tags, + Status: pm.Status, + GroupID: sql.NullString{Valid: pm.Group.Valid, String: pm.Group.Value}, + ClientID: pm.Client, + ConnType: connType, + RoleName: pm.RoleName, + RoleID: pm.RoleID, + Actions: pm.Actions, + AccessType: pm.AccessType, + CreatedFrom: pm.CreatedFrom, + CreatedTo: pm.CreatedTo, }, nil } type dbChannelsPage struct { - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Name string `db:"name"` - Id string `db:"id"` - Domain string `db:"domain_id"` - Metadata []byte `db:"metadata"` - Tags pgtype.TextArray `db:"tags"` - Status channels.Status `db:"status"` - GroupID sql.NullString `db:"group_id"` - ClientID string `db:"client_id"` - ConnType uint8 `db:"conn_type"` - RoleName string `db:"role_name"` - RoleID string `db:"role_id"` - Actions pq.StringArray `db:"actions"` - AccessType string `db:"access_type"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Name string `db:"name"` + Id string `db:"id"` + Domain string `db:"domain_id"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + Status channels.Status `db:"status"` + GroupID sql.NullString `db:"group_id"` + ClientID string `db:"client_id"` + ConnType uint8 `db:"conn_type"` + RoleName string `db:"role_name"` + RoleID string `db:"role_id"` + Actions pq.StringArray `db:"actions"` + AccessType string `db:"access_type"` + CreatedFrom time.Time `db:"created_from"` + CreatedTo time.Time `db:"created_to"` } type dbConnection struct { diff --git a/channels/postgres/channels_test.go b/channels/postgres/channels_test.go index 835dd136b..5c33069af 100644 --- a/channels/postgres/channels_test.go +++ b/channels/postgres/channels_test.go @@ -604,7 +604,7 @@ func TestRetrieveAll(t *testing.T) { var items []channels.Channel parentID := "" - baseTime := time.Now().UTC().Truncate(time.Microsecond) + baseTime := time.Now().UTC().Truncate(time.Millisecond) for i := 0; i < num; i++ { name := namegen.Generate() channel := channels.Channel{ @@ -1139,6 +1139,112 @@ func TestRetrieveAll(t *testing.T) { }, err: nil, }, + { + desc: "retrieve channels with created_from", + page: channels.ChannelsPage{ + Page: channels.Page{ + Offset: 0, + Limit: 200, + Order: "created_at", + Dir: ascDir, + CreatedFrom: baseTime.Add(100 * time.Millisecond), + }, + }, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 100, + Offset: 0, + Limit: 200, + }, + Channels: items[100:], + }, + err: nil, + }, + { + desc: "retrieve channels with created_to", + page: channels.ChannelsPage{ + Page: channels.Page{ + Offset: 0, + Limit: 200, + Order: "created_at", + Dir: ascDir, + CreatedTo: baseTime.Add(99 * time.Millisecond), + }, + }, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 100, + Offset: 0, + Limit: 200, + }, + Channels: items[:100], + }, + err: nil, + }, + { + desc: "retrieve channels with both created_from and created_to", + page: channels.ChannelsPage{ + Page: channels.Page{ + Offset: 0, + Limit: 200, + Order: "created_at", + Dir: ascDir, + CreatedFrom: baseTime.Add(50 * time.Millisecond), + CreatedTo: baseTime.Add(149 * time.Millisecond), + }, + }, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 100, + Offset: 0, + Limit: 200, + }, + Channels: items[50:150], + }, + err: nil, + }, + { + desc: "retrieve channels with created_from returning no results", + page: channels.ChannelsPage{ + Page: channels.Page{ + Offset: 0, + Limit: 10, + Order: "created_at", + Dir: ascDir, + CreatedFrom: baseTime.Add(1000 * time.Millisecond), + }, + }, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 0, + Offset: 0, + Limit: 10, + }, + Channels: []channels.Channel{}, + }, + err: nil, + }, + { + desc: "retrieve channels with created_to returning no results", + page: channels.ChannelsPage{ + Page: channels.Page{ + Offset: 0, + Limit: 10, + Order: "created_at", + Dir: ascDir, + CreatedTo: baseTime.Add(-1 * time.Millisecond), + }, + }, + response: channels.ChannelsPage{ + Page: channels.Page{ + Total: 0, + Offset: 0, + Limit: 10, + }, + Channels: []channels.Channel{}, + }, + err: nil, + }, } for _, tc := range cases { @@ -2728,7 +2834,7 @@ func TestRetrieveUserChannels(t *testing.T) { }, }, { - desc: "retrieve channels with actions filter", + desc: "retrieve channels with actions", domainID: domain.ID, userID: userID, pm: channels.Page{ @@ -2749,7 +2855,7 @@ func TestRetrieveUserChannels(t *testing.T) { }, }, { - desc: "retrieve channels with non-matching actions filter", + desc: "retrieve channels with non-matching actions", domainID: domain.ID, userID: userID, pm: channels.Page{ diff --git a/clients/api/http/decode.go b/clients/api/http/decode.go index 14b0648f9..1cc990cf8 100644 --- a/clients/api/http/decode.go +++ b/clients/api/http/decode.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "strings" + "time" api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" @@ -140,6 +141,27 @@ func decodeListClients(_ context.Context, r *http.Request) (any, error) { return listClientsReq{}, errors.Wrap(apiutil.ErrValidation, err) } + cfrom, err := apiutil.ReadStringQuery(r, "created_from", "") + if err != nil { + return listClientsReq{}, errors.Wrap(apiutil.ErrValidation, err) + } + cto, err := apiutil.ReadStringQuery(r, "created_to", "") + if err != nil { + return listClientsReq{}, errors.Wrap(apiutil.ErrValidation, err) + } + + var createdFrom, createdTo time.Time + if cfrom != "" { + if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil { + return listClientsReq{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + if cto != "" { + if createdTo, err = time.Parse(time.RFC3339, cto); err != nil { + return listClientsReq{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + req := listClientsReq{ Page: clients.Page{ Name: name, @@ -159,6 +181,8 @@ func decodeListClients(_ context.Context, r *http.Request) (any, error) { ConnectionType: connType, ID: id, OnlyTotal: ot, + CreatedFrom: createdFrom, + CreatedTo: createdTo, }, userID: userID, } diff --git a/clients/api/http/endpoints_test.go b/clients/api/http/endpoints_test.go index a5acdc48e..4c3b5dfc6 100644 --- a/clients/api/http/endpoints_test.go +++ b/clients/api/http/endpoints_test.go @@ -12,6 +12,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/0x6flab/namegenerator" api "github.com/absmach/supermq/api/http" @@ -32,6 +33,8 @@ import ( "github.com/stretchr/testify/mock" ) +const contentType = "application/json" + var ( secret = "strongsecret" validMetadata = clients.Metadata{"role": "client"} @@ -45,16 +48,15 @@ var ( Metadata: validMetadata, Status: clients.EnabledStatus, } - validToken = "token" - inValidToken = "invalid" - inValid = "invalid" - validID = testsutil.GenerateUUID(&testing.T{}) - domainID = testsutil.GenerateUUID(&testing.T{}) - namesgen = namegenerator.NewGenerator() + validToken = "token" + inValidToken = "invalid" + inValid = "invalid" + validID = testsutil.GenerateUUID(&testing.T{}) + domainID = testsutil.GenerateUUID(&testing.T{}) + namesgen = namegenerator.NewGenerator() + validTimeStamp = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) ) -const contentType = "application/json" - type testRequest struct { client *http.Client method string @@ -759,6 +761,112 @@ func TestListClients(t *testing.T) { status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, + { + desc: "list clients with created_from parameter", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedFrom: validTimeStamp, + }, + listClientsResponse: clients.ClientsPage{ + Page: clients.Page{ + Total: 1, + }, + Clients: []clients.Client{client}, + }, + query: "created_from=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list clients with created_to parameter", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedTo: validTimeStamp, + }, + listClientsResponse: clients.ClientsPage{ + Page: clients.Page{ + Total: 1, + }, + Clients: []clients.Client{client}, + }, + query: "created_to=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list clients with both created_from and created_to parameters", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + pageMeta: clients.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedFrom: validTimeStamp, + CreatedTo: validTimeStamp, + }, + listClientsResponse: clients.ClientsPage{ + Page: clients.Page{ + Total: 1, + }, + Clients: []clients.Client{client}, + }, + query: "created_from=2024-01-01T00:00:00Z&created_to=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list clients with invalid created_from", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + query: "created_from=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list clients with duplicate created_from", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + query: "created_from=2024-01-01T00:00:00Z&created_from=2024-01-01T00:00:00Z", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list clients with invalid created_to", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + query: "created_to=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list clients with duplicate created_to", + domainID: domainID, + token: validToken, + authnRes: smqauthn.Session{UserID: validID, DomainID: domainID, DomainUserID: domainID + "_" + validID, SuperAdmin: false}, + query: "created_to=2024-12-31T23:59:59Z&created_to=2024-12-31T23:59:59Z", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, } for _, tc := range cases { diff --git a/clients/clients.go b/clients/clients.go index ea1cf5caa..b30cd29f2 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -251,6 +251,8 @@ type Page struct { Actions []string `json:"actions,omitempty"` AccessType string `json:"access_type,omitempty"` IDs []string `json:"-"` + CreatedFrom time.Time `json:"created_from,omitempty"` + CreatedTo time.Time `json:"created_to,omitempty"` } // Metadata represents arbitrary JSON. diff --git a/clients/postgres/clients.go b/clients/postgres/clients.go index 3bf69db10..fc93cf5e9 100644 --- a/clients/postgres/clients.go +++ b/clients/postgres/clients.go @@ -1190,42 +1190,46 @@ func ToDBClientsPage(pm clients.Page) (dbClientsPage, error) { return dbClientsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } return dbClientsPage{ - Offset: pm.Offset, - Limit: pm.Limit, - Name: pm.Name, - Identity: pm.Identity, - Id: pm.ID, - Metadata: data, - Domain: pm.Domain, - Status: pm.Status, - Tags: tags, - GroupID: pm.Group, - ChannelID: pm.Channel, - RoleName: pm.RoleName, - ConnType: pm.ConnectionType, - RoleID: pm.RoleID, - Actions: pm.Actions, - AccessType: pm.AccessType, + Offset: pm.Offset, + Limit: pm.Limit, + Name: pm.Name, + Identity: pm.Identity, + Id: pm.ID, + Metadata: data, + Domain: pm.Domain, + Status: pm.Status, + Tags: tags, + GroupID: pm.Group, + ChannelID: pm.Channel, + RoleName: pm.RoleName, + ConnType: pm.ConnectionType, + RoleID: pm.RoleID, + Actions: pm.Actions, + AccessType: pm.AccessType, + CreatedFrom: pm.CreatedFrom, + CreatedTo: pm.CreatedTo, }, nil } type dbClientsPage struct { - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Name string `db:"name"` - Id string `db:"id"` - Domain string `db:"domain_id"` - Identity string `db:"identity"` - Metadata []byte `db:"metadata"` - Tags pgtype.TextArray `db:"tags"` - Status clients.Status `db:"status"` - GroupID *string `db:"group_id"` - ChannelID string `db:"channel_id"` - ConnType string `db:"type"` - RoleName string `db:"role_name"` - RoleID string `db:"role_id"` - Actions pq.StringArray `db:"actions"` - AccessType string `db:"access_type"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Name string `db:"name"` + Id string `db:"id"` + Domain string `db:"domain_id"` + Identity string `db:"identity"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + Status clients.Status `db:"status"` + GroupID *string `db:"group_id"` + ChannelID string `db:"channel_id"` + ConnType string `db:"type"` + RoleName string `db:"role_name"` + RoleID string `db:"role_id"` + Actions pq.StringArray `db:"actions"` + AccessType string `db:"access_type"` + CreatedFrom time.Time `db:"created_from"` + CreatedTo time.Time `db:"created_to"` } func PageQuery(pm clients.Page) (string, error) { @@ -1289,6 +1293,13 @@ func PageQuery(pm clients.Page) (string, error) { query = append(query, "c.metadata @> :metadata") } + if !pm.CreatedFrom.IsZero() { + query = append(query, "c.created_at >= :created_from") + } + if !pm.CreatedTo.IsZero() { + query = append(query, "c.created_at <= :created_to") + } + var emq string if len(query) > 0 { emq = fmt.Sprintf("WHERE %s", strings.Join(query, " AND ")) diff --git a/clients/postgres/clients_test.go b/clients/postgres/clients_test.go index f1ccd8cfb..a1ee1ad40 100644 --- a/clients/postgres/clients_test.go +++ b/clients/postgres/clients_test.go @@ -1109,7 +1109,7 @@ func TestRetrieveAll(t *testing.T) { expectedClients := []clients.Client{} disabledClients := []clients.Client{} reversedClients := []clients.Client{} - baseTime := time.Now().UTC().Truncate(time.Microsecond) + baseTime := time.Now().UTC().Truncate(time.Millisecond) for i := uint64(0); i < nClients; i++ { client := clients.Client{ ID: testsutil.GenerateUUID(t), @@ -1124,8 +1124,8 @@ func TestRetrieveAll(t *testing.T) { "department": namegen.Generate(), }, Status: clients.EnabledStatus, - CreatedAt: baseTime.Add(time.Duration(i) * time.Microsecond), - UpdatedAt: baseTime.Add(time.Duration(i) * time.Microsecond), + CreatedAt: baseTime.Add(time.Duration(i) * time.Millisecond), + UpdatedAt: baseTime.Add(time.Duration(i) * time.Millisecond), } if i%50 == 0 { client.Status = clients.DisabledStatus @@ -1809,6 +1809,102 @@ func TestRetrieveAll(t *testing.T) { }, }, }, + { + desc: "with created_from", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Status: clients.AllStatus, + CreatedFrom: baseTime.Add(100 * time.Millisecond), + Order: defOrder, + Dir: ascDir, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 100, + Offset: 0, + Limit: nClients, + }, + Clients: expectedClients[100:], + }, + }, + { + desc: "with created_to", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Status: clients.AllStatus, + CreatedTo: baseTime.Add(99 * time.Millisecond), + Order: defOrder, + Dir: ascDir, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 100, + Offset: 0, + Limit: nClients, + }, + Clients: expectedClients[:100], + }, + }, + { + desc: "with both created_from and created_to", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Status: clients.AllStatus, + CreatedFrom: baseTime.Add(50 * time.Millisecond), + CreatedTo: baseTime.Add(149 * time.Millisecond), + Order: defOrder, + Dir: ascDir, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 100, + Offset: 0, + Limit: nClients, + }, + Clients: expectedClients[50:150], + }, + }, + { + desc: "with created_from returning no results", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Status: clients.AllStatus, + CreatedFrom: baseTime.Add(500 * time.Millisecond), + Order: defOrder, + Dir: ascDir, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 0, + Offset: 0, + Limit: nClients, + }, + Clients: []clients.Client(nil), + }, + }, + { + desc: "with created_to returning no results", + pm: clients.Page{ + Offset: 0, + Limit: nClients, + Status: clients.AllStatus, + CreatedTo: baseTime.Add(-10 * time.Millisecond), + Order: defOrder, + Dir: ascDir, + }, + response: clients.ClientsPage{ + Page: clients.Page{ + Total: 0, + Offset: 0, + Limit: nClients, + }, + Clients: []clients.Client(nil), + }, + }, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { diff --git a/domains/api/http/decode.go b/domains/api/http/decode.go index 72a5e018c..eac7e6907 100644 --- a/domains/api/http/decode.go +++ b/domains/api/http/decode.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "strings" + "time" api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" @@ -170,20 +171,43 @@ func decodePageRequest(_ context.Context, r *http.Request) (domains.Page, error) return domains.Page{}, errors.Wrap(apiutil.ErrValidation, err) } + cfrom, err := apiutil.ReadStringQuery(r, "created_from", "") + if err != nil { + return domains.Page{}, errors.Wrap(apiutil.ErrValidation, err) + } + cto, err := apiutil.ReadStringQuery(r, "created_to", "") + if err != nil { + return domains.Page{}, errors.Wrap(apiutil.ErrValidation, err) + } + + var createdFrom, createdTo time.Time + if cfrom != "" { + if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil { + return domains.Page{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + if cto != "" { + if createdTo, err = time.Parse(time.RFC3339, cto); err != nil { + return domains.Page{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + return domains.Page{ - Offset: o, - Order: or, - Dir: dir, - Limit: l, - Name: n, - Metadata: m, - Tags: tq, - RoleID: roleID, - RoleName: roleName, - Actions: actions, - Status: st, - ID: id, - OnlyTotal: ot, + Offset: o, + Order: or, + Dir: dir, + Limit: l, + Name: n, + Metadata: m, + Tags: tq, + RoleID: roleID, + RoleName: roleName, + Actions: actions, + Status: st, + ID: id, + OnlyTotal: ot, + CreatedFrom: createdFrom, + CreatedTo: createdTo, }, nil } diff --git a/domains/api/http/endpoint_test.go b/domains/api/http/endpoint_test.go index 9d0ce62dc..69dc863f7 100644 --- a/domains/api/http/endpoint_test.go +++ b/domains/api/http/endpoint_test.go @@ -32,6 +32,8 @@ import ( "github.com/stretchr/testify/mock" ) +const contentType = "application/json" + var ( validMetadata = domains.Metadata{"role": "client"} ID = testsutil.GenerateUUID(&testing.T{}) @@ -51,12 +53,6 @@ var ( domainID = testsutil.GenerateUUID(&testing.T{}) ) -const ( - contentType = "application/json" - refreshDuration = 24 * time.Hour - invalidDuration = 7 * 24 * time.Hour -) - type testRequest struct { client *http.Client method string @@ -619,6 +615,89 @@ func TestListDomains(t *testing.T) { svcErr: svcerr.ErrViewEntity, err: svcerr.ErrViewEntity, }, + { + desc: "list domains with created_from parameter", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z", + page: domains.Page{ + Offset: api.DefOffset, + Limit: api.DefLimit, + Order: api.DefOrder, + Dir: api.DefDir, + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + listDomainsResp: domains.DomainsPage{ + Total: 1, + Domains: []domains.Domain{domain}, + }, + status: http.StatusOK, + err: nil, + }, + { + desc: "list domains with created_to parameter", + token: validToken, + query: "created_to=2024-12-31T23:59:59Z", + page: domains.Page{ + Offset: api.DefOffset, + Limit: api.DefLimit, + Order: api.DefOrder, + Dir: api.DefDir, + CreatedTo: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), + }, + listDomainsResp: domains.DomainsPage{ + Total: 1, + Domains: []domains.Domain{domain}, + }, + status: http.StatusOK, + err: nil, + }, + { + desc: "list domains with both created_from and created_to parameters", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z&created_to=2024-12-31T23:59:59Z", + page: domains.Page{ + Offset: api.DefOffset, + Limit: api.DefLimit, + Order: api.DefOrder, + Dir: api.DefDir, + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedTo: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), + }, + listDomainsResp: domains.DomainsPage{ + Total: 1, + Domains: []domains.Domain{domain}, + }, + status: http.StatusOK, + err: nil, + }, + { + desc: "list domains with invalid created_from", + token: validToken, + query: "created_from=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list domains with duplicate created_from", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z&created_from=2024-01-02T00:00:00Z", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list domains with invalid created_to", + token: validToken, + query: "created_to=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list domains with duplicate created_to", + token: validToken, + query: "created_to=2024-12-31T23:59:59Z&created_to=2025-01-01T00:00:00Z", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, } for _, tc := range cases { diff --git a/domains/domains.go b/domains/domains.go index 4c5947263..6461f1c4b 100644 --- a/domains/domains.go +++ b/domains/domains.go @@ -155,23 +155,25 @@ func ToTagsQuery(s string) TagsQuery { } type Page struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Name string `json:"name,omitempty"` - Order string `json:"-"` - Dir string `json:"-"` - Metadata Metadata `json:"metadata,omitempty"` - Tags TagsQuery `json:"tags,omitempty"` - RoleName string `json:"role_name,omitempty"` - RoleID string `json:"role_id,omitempty"` - Actions []string `json:"actions,omitempty"` - Status Status `json:"status,omitempty"` - ID string `json:"id,omitempty"` - IDs []string `json:"-"` - Identity string `json:"identity,omitempty"` - UserID string `json:"user_id,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Name string `json:"name,omitempty"` + Order string `json:"-"` + Dir string `json:"-"` + Metadata Metadata `json:"metadata,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` + RoleName string `json:"role_name,omitempty"` + RoleID string `json:"role_id,omitempty"` + Actions []string `json:"actions,omitempty"` + Status Status `json:"status,omitempty"` + ID string `json:"id,omitempty"` + IDs []string `json:"-"` + Identity string `json:"identity,omitempty"` + UserID string `json:"user_id,omitempty"` + CreatedFrom time.Time `json:"created_from,omitempty"` + CreatedTo time.Time `json:"created_to,omitempty"` } type DomainsPage struct { diff --git a/domains/postgres/domains.go b/domains/postgres/domains.go index f08730b42..c5316ab87 100644 --- a/domains/postgres/domains.go +++ b/domains/postgres/domains.go @@ -660,21 +660,23 @@ func toDomain(d dbDomain) (domains.Domain, error) { } type dbDomainsPage struct { - Total uint64 `db:"total"` - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Order string `db:"order"` - Dir string `db:"dir"` - Name string `db:"name"` - RoleID string `db:"role_id"` - RoleName string `db:"role_name"` - Actions pq.StringArray `db:"actions"` - ID string `db:"id"` - IDs []string `db:"ids"` - Metadata []byte `db:"metadata"` - Tags pgtype.TextArray `db:"tags"` - Status domains.Status `db:"status"` - UserID string `db:"member_id"` + Total uint64 `db:"total"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Order string `db:"order"` + Dir string `db:"dir"` + Name string `db:"name"` + RoleID string `db:"role_id"` + RoleName string `db:"role_name"` + Actions pq.StringArray `db:"actions"` + ID string `db:"id"` + IDs []string `db:"ids"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + Status domains.Status `db:"status"` + UserID string `db:"member_id"` + CreatedFrom time.Time `db:"created_from"` + CreatedTo time.Time `db:"created_to"` } func toDBDomainsPage(pm domains.Page) (dbDomainsPage, error) { @@ -687,21 +689,23 @@ func toDBDomainsPage(pm domains.Page) (dbDomainsPage, error) { return dbDomainsPage{}, errors.Wrap(repoerr.ErrViewEntity, err) } return dbDomainsPage{ - Total: pm.Total, - Limit: pm.Limit, - Offset: pm.Offset, - Order: pm.Order, - Dir: pm.Dir, - Name: pm.Name, - RoleID: pm.RoleID, - RoleName: pm.RoleName, - Actions: pm.Actions, - ID: pm.ID, - IDs: pm.IDs, - Metadata: data, - Tags: tags, - Status: pm.Status, - UserID: pm.UserID, + Total: pm.Total, + Limit: pm.Limit, + Offset: pm.Offset, + Order: pm.Order, + Dir: pm.Dir, + Name: pm.Name, + RoleID: pm.RoleID, + RoleName: pm.RoleName, + Actions: pm.Actions, + ID: pm.ID, + IDs: pm.IDs, + Metadata: data, + Tags: tags, + Status: pm.Status, + UserID: pm.UserID, + CreatedFrom: pm.CreatedFrom, + CreatedTo: pm.CreatedTo, }, nil } @@ -758,6 +762,13 @@ func buildPageQuery(pm domains.Page) (string, error) { query = append(query, mq) } + if !pm.CreatedFrom.IsZero() { + query = append(query, "d.created_at >= :created_from") + } + if !pm.CreatedTo.IsZero() { + query = append(query, "d.created_at <= :created_to") + } + if len(query) > 0 { emq = fmt.Sprintf("WHERE %s", strings.Join(query, " AND ")) } diff --git a/domains/postgres/domains_test.go b/domains/postgres/domains_test.go index 6a6ede37f..bb05e4978 100644 --- a/domains/postgres/domains_test.go +++ b/domains/postgres/domains_test.go @@ -1063,6 +1063,97 @@ func TestListDomains(t *testing.T) { }, err: nil, }, + { + desc: "list domains with created_from filter", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Status: domains.AllStatus, + CreatedFrom: baseTime.Add(5 * time.Millisecond), + Order: "created_at", + Dir: ascDir, + }, + response: domains.DomainsPage{ + Total: 5, + Offset: 0, + Limit: 10, + Domains: []domains.Domain{items[5], items[6], items[7], items[8], items[9]}, + }, + err: nil, + }, + { + desc: "list domains with created_to filter", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Status: domains.AllStatus, + CreatedTo: baseTime.Add(4 * time.Millisecond), + Order: "created_at", + Dir: ascDir, + }, + response: domains.DomainsPage{ + Total: 5, + Offset: 0, + Limit: 10, + Domains: []domains.Domain{items[0], items[1], items[2], items[3], items[4]}, + }, + err: nil, + }, + { + desc: "list domains with both created_from and created_to filters", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Status: domains.AllStatus, + CreatedFrom: baseTime.Add(2 * time.Millisecond), + CreatedTo: baseTime.Add(7 * time.Millisecond), + Order: "created_at", + Dir: ascDir, + }, + response: domains.DomainsPage{ + Total: 6, + Offset: 0, + Limit: 10, + Domains: []domains.Domain{items[2], items[3], items[4], items[5], items[6], items[7]}, + }, + err: nil, + }, + { + desc: "list domains with created_from filter returning no results", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Status: domains.AllStatus, + CreatedFrom: baseTime.Add(20 * time.Millisecond), + Order: "created_at", + Dir: ascDir, + }, + response: domains.DomainsPage{ + Total: 0, + Offset: 0, + Limit: 10, + Domains: []domains.Domain(nil), + }, + err: nil, + }, + { + desc: "list domains with created_to filter returning no results", + pm: domains.Page{ + Offset: 0, + Limit: 10, + Status: domains.AllStatus, + CreatedTo: baseTime.Add(-10 * time.Millisecond), + Order: "created_at", + Dir: ascDir, + }, + response: domains.DomainsPage{ + Total: 0, + Offset: 0, + Limit: 10, + Domains: []domains.Domain(nil), + }, + err: nil, + }, } for _, tc := range cases { diff --git a/groups/api/http/decode.go b/groups/api/http/decode.go index 772dba5f0..fbbd7087a 100644 --- a/groups/api/http/decode.go +++ b/groups/api/http/decode.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "strings" + "time" api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" @@ -301,22 +302,45 @@ func decodePageMeta(r *http.Request) (groups.PageMeta, error) { tq = groups.ToTagsQuery(tags) } + cfrom, err := apiutil.ReadStringQuery(r, "created_from", "") + if err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) + } + cto, err := apiutil.ReadStringQuery(r, "created_to", "") + if err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrValidation, err) + } + + var createdFrom, createdTo time.Time + if cfrom != "" { + if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + if cto != "" { + if createdTo, err = time.Parse(time.RFC3339, cto); err != nil { + return groups.PageMeta{}, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + ret := groups.PageMeta{ - Offset: offset, - Limit: limit, - Name: name, - ID: id, - Metadata: meta, - Status: st, - RoleName: roleName, - RoleID: roleID, - Actions: actions, - AccessType: accessType, - RootGroup: rootGroup, - OnlyTotal: ot, - Order: order, - Dir: dir, - Tags: tq, + Offset: offset, + Limit: limit, + Name: name, + ID: id, + Metadata: meta, + Status: st, + RoleName: roleName, + RoleID: roleID, + Actions: actions, + AccessType: accessType, + RootGroup: rootGroup, + OnlyTotal: ot, + Order: order, + Dir: dir, + Tags: tq, + CreatedFrom: createdFrom, + CreatedTo: createdTo, } return ret, nil } diff --git a/groups/api/http/decode_test.go b/groups/api/http/decode_test.go index ce6f7b248..77db902a0 100644 --- a/groups/api/http/decode_test.go +++ b/groups/api/http/decode_test.go @@ -10,6 +10,7 @@ import ( "net/url" "strings" "testing" + "time" api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" @@ -68,6 +69,61 @@ func TestDecodeListGroupsRequest(t *testing.T) { resp: nil, err: apiutil.ErrValidation, }, + { + desc: "valid request with created_from parameter", + url: "http://localhost:8080?created_from=2024-01-01T00:00:00Z", + resp: listGroupsReq{ + PageMeta: groups.PageMeta{ + Limit: 10, + Actions: []string{}, + Dir: "desc", + Order: "updated_at", + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + err: nil, + }, + { + desc: "valid request with created_to parameter", + url: "http://localhost:8080?created_to=2024-12-31T23:59:59Z", + resp: listGroupsReq{ + PageMeta: groups.PageMeta{ + Limit: 10, + Actions: []string{}, + Dir: "desc", + Order: "updated_at", + CreatedTo: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), + }, + }, + err: nil, + }, + { + desc: "valid request with both created_from and created_to parameters", + url: "http://localhost:8080?created_from=2024-01-01T00:00:00Z&created_to=2024-12-31T23:59:59Z", + resp: listGroupsReq{ + PageMeta: groups.PageMeta{ + Limit: 10, + Actions: []string{}, + Dir: "desc", + Order: "updated_at", + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedTo: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC), + }, + }, + err: nil, + }, + { + desc: "invalid request with malformed created_from", + url: "http://localhost:8080?created_from=invalid-timestamp", + resp: nil, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "invalid request with malformed created_to", + url: "http://localhost:8080?created_to=invalid-timestamp", + resp: nil, + err: apiutil.ErrInvalidQueryParams, + }, } for _, tc := range cases { diff --git a/groups/api/http/endpoint_test.go b/groups/api/http/endpoint_test.go index 70eedd0c3..129e86ae4 100644 --- a/groups/api/http/endpoint_test.go +++ b/groups/api/http/endpoint_test.go @@ -49,10 +49,11 @@ var ( UpdatedBy: testsutil.GenerateUUID(&testing.T{}), Status: groups.EnabledStatus, } - validID = testsutil.GenerateUUID(&testing.T{}) - validToken = "validToken" - invalidToken = "invalidToken" - contentType = "application/json" + validID = testsutil.GenerateUUID(&testing.T{}) + validToken = "validToken" + invalidToken = "invalidToken" + contentType = "application/json" + validTimeStamp = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) ) func newGroupsServer() (*httptest.Server, *mocks.Service, *authnmocks.Authentication) { @@ -1121,6 +1122,89 @@ func TestListGroups(t *testing.T) { status: http.StatusBadRequest, err: apiutil.ErrInvalidQueryParams, }, + { + desc: "list groups with created_from", + domainID: validID, + token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedFrom: validTimeStamp, + }, + listGroupsResponse: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 1, + }, + Groups: []groups.Group{validGroupResp}, + }, + query: "created_from=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list groups with created_to", + domainID: validID, + token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedTo: validTimeStamp, + }, + listGroupsResponse: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 1, + }, + Groups: []groups.Group{validGroupResp}, + }, + query: "created_to=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list groups with both created_from and created_to", + domainID: validID, + token: validToken, + pageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Actions: []string{}, + CreatedFrom: validTimeStamp, + CreatedTo: validTimeStamp, + }, + listGroupsResponse: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 1, + }, + Groups: []groups.Group{validGroupResp}, + }, + query: "created_from=2024-01-01T00:00:00Z&created_to=2024-01-01T00:00:00Z", + status: http.StatusOK, + err: nil, + }, + { + desc: "list groups with invalid created_from", + domainID: validID, + token: validToken, + query: "created_from=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list groups with invalid created_to", + domainID: validID, + token: validToken, + query: "created_to=invalid-timestamp", + status: http.StatusBadRequest, + err: apiutil.ErrInvalidQueryParams, + }, } for _, tc := range cases { diff --git a/groups/page.go b/groups/page.go index a1f0f8a55..14dfb48c9 100644 --- a/groups/page.go +++ b/groups/page.go @@ -3,7 +3,10 @@ package groups -import "strings" +import ( + "strings" + "time" +) type Operator uint8 @@ -38,22 +41,24 @@ func ToTagsQuery(s string) TagsQuery { // PageMeta contains page metadata that helps navigation. type PageMeta struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Name string `json:"name,omitempty"` - ID string `json:"id,omitempty"` - Dir string `json:"dir,omitempty"` - Order string `json:"order,omitempty"` - Path string `json:"path,omitempty"` - DomainID string `json:"domain_id,omitempty"` - Tags TagsQuery `json:"tags,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Status Status `json:"status,omitempty"` - RoleName string `json:"role_name,omitempty"` - RoleID string `json:"role_id,omitempty"` - Actions []string `json:"actions,omitempty"` - AccessType string `json:"access_type,omitempty"` - RootGroup bool `json:"root_group,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Dir string `json:"dir,omitempty"` + Order string `json:"order,omitempty"` + Path string `json:"path,omitempty"` + DomainID string `json:"domain_id,omitempty"` + Tags TagsQuery `json:"tags,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Status Status `json:"status,omitempty"` + RoleName string `json:"role_name,omitempty"` + RoleID string `json:"role_id,omitempty"` + Actions []string `json:"actions,omitempty"` + AccessType string `json:"access_type,omitempty"` + RootGroup bool `json:"root_group,omitempty"` + CreatedFrom time.Time `json:"created_from,omitempty"` + CreatedTo time.Time `json:"created_to,omitempty"` } diff --git a/groups/postgres/groups.go b/groups/postgres/groups.go index f5059003d..a1cdfd39d 100644 --- a/groups/postgres/groups.go +++ b/groups/postgres/groups.go @@ -1196,6 +1196,12 @@ func buildQuery(gm groups.PageMeta, ids ...string) string { if len(gm.Metadata) > 0 { queries = append(queries, "g.metadata @> :metadata") } + if !gm.CreatedFrom.IsZero() { + queries = append(queries, "g.created_at >= :created_from") + } + if !gm.CreatedTo.IsZero() { + queries = append(queries, "g.created_at <= :created_to") + } if len(queries) > 0 { return fmt.Sprintf("WHERE %s", strings.Join(queries, " AND ")) } @@ -1341,40 +1347,44 @@ func toDBGroupPageMeta(pm groups.PageMeta) (dbGroupPageMeta, error) { return dbGroupPageMeta{}, errors.Wrap(repoerr.ErrViewEntity, err) } return dbGroupPageMeta{ - ID: pm.ID, - Name: pm.Name, - Metadata: data, - Tags: tags, - Total: pm.Total, - Offset: pm.Offset, - Limit: pm.Limit, - DomainID: pm.DomainID, - Status: pm.Status, - RoleName: pm.RoleName, - RoleID: pm.RoleID, - Actions: pm.Actions, - AccessType: pm.AccessType, + ID: pm.ID, + Name: pm.Name, + Metadata: data, + Tags: tags, + Total: pm.Total, + Offset: pm.Offset, + Limit: pm.Limit, + DomainID: pm.DomainID, + Status: pm.Status, + RoleName: pm.RoleName, + RoleID: pm.RoleID, + Actions: pm.Actions, + AccessType: pm.AccessType, + CreatedFrom: pm.CreatedFrom, + CreatedTo: pm.CreatedTo, }, nil } type dbGroupPageMeta struct { - ID string `db:"id"` - Name string `db:"name"` - ParentID string `db:"parent_id"` - DomainID string `db:"domain_id"` - Metadata []byte `db:"metadata"` - Path string `db:"path"` - Level uint64 `db:"level"` - Total uint64 `db:"total"` - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - Subject string `db:"subject"` - RoleName string `db:"role_name"` - RoleID string `db:"role_id"` - Actions pq.StringArray `db:"actions"` - AccessType string `db:"access_type"` - Status groups.Status `db:"status"` - Tags pgtype.TextArray `db:"tags"` + ID string `db:"id"` + Name string `db:"name"` + ParentID string `db:"parent_id"` + DomainID string `db:"domain_id"` + Metadata []byte `db:"metadata"` + Path string `db:"path"` + Level uint64 `db:"level"` + Total uint64 `db:"total"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + Subject string `db:"subject"` + RoleName string `db:"role_name"` + RoleID string `db:"role_id"` + Actions pq.StringArray `db:"actions"` + AccessType string `db:"access_type"` + Status groups.Status `db:"status"` + Tags pgtype.TextArray `db:"tags"` + CreatedFrom time.Time `db:"created_from"` + CreatedTo time.Time `db:"created_to"` } func (repo groupRepository) processRows(rows *sqlx.Rows) ([]groups.Group, error) { diff --git a/groups/postgres/groups_test.go b/groups/postgres/groups_test.go index aff7395b2..3a3377a87 100644 --- a/groups/postgres/groups_test.go +++ b/groups/postgres/groups_test.go @@ -675,7 +675,7 @@ func TestRetrieveAll(t *testing.T) { repo := postgres.New(database) num := 200 - baseTime := time.Now().UTC().Truncate(time.Microsecond) + baseTime := time.Now().UTC().Truncate(time.Millisecond) var items []groups.Group parentID := "" @@ -688,8 +688,8 @@ func TestRetrieveAll(t *testing.T) { Name: name, Description: desc, Metadata: map[string]any{"name": name}, - CreatedAt: baseTime.Add(time.Duration(i) * time.Microsecond), - UpdatedAt: baseTime.Add(time.Duration(i) * time.Microsecond), + CreatedAt: baseTime.Add(time.Duration(i) * time.Millisecond), + UpdatedAt: baseTime.Add(time.Duration(i) * time.Millisecond), Status: groups.EnabledStatus, Tags: []string{"tag1", "tag2"}, } @@ -1178,6 +1178,112 @@ func TestRetrieveAll(t *testing.T) { Groups: []groups.Group(nil), }, }, + { + desc: "retrieve groups with created_from", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: 200, + Order: "created_at", + Dir: ascDir, + CreatedFrom: baseTime.Add(100 * time.Millisecond), + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 100, + Offset: 0, + Limit: 200, + }, + Groups: items[100:], + }, + err: nil, + }, + { + desc: "retrieve groups with created_to", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: 200, + Order: "created_at", + Dir: ascDir, + CreatedTo: baseTime.Add(99 * time.Millisecond), + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 100, + Offset: 0, + Limit: 200, + }, + Groups: items[:100], + }, + err: nil, + }, + { + desc: "retrieve groups with both created_from and created_to", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: 200, + Order: "created_at", + Dir: ascDir, + CreatedFrom: baseTime.Add(50 * time.Millisecond), + CreatedTo: baseTime.Add(149 * time.Millisecond), + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 100, + Offset: 0, + Limit: 200, + }, + Groups: items[50:150], + }, + err: nil, + }, + { + desc: "retrieve groups with created_from returning no results", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: "created_at", + Dir: ascDir, + CreatedFrom: baseTime.Add(1000 * time.Millisecond), + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 0, + Offset: 0, + Limit: 10, + }, + Groups: []groups.Group(nil), + }, + err: nil, + }, + { + desc: "retrieve groups with created_to returning no results", + page: groups.Page{ + PageMeta: groups.PageMeta{ + Offset: 0, + Limit: 10, + Order: "created_at", + Dir: ascDir, + CreatedTo: baseTime.Add(-1 * time.Millisecond), + }, + }, + response: groups.Page{ + PageMeta: groups.PageMeta{ + Total: 0, + Offset: 0, + Limit: 10, + }, + Groups: []groups.Group(nil), + }, + err: nil, + }, } for _, tc := range cases { diff --git a/pkg/sdk/sdk.go b/pkg/sdk/sdk.go index 8f07f0b53..f1fd5c10b 100644 --- a/pkg/sdk/sdk.go +++ b/pkg/sdk/sdk.go @@ -162,6 +162,8 @@ type PageMetadata struct { Tree bool `json:"tree,omitempty"` StartLevel int64 `json:"start_level,omitempty"` EndLevel int64 `json:"end_level,omitempty"` + CreatedFrom time.Time `json:"created_from,omitempty"` + CreatedTo time.Time `json:"created_to,omitempty"` } type Role struct { @@ -1702,6 +1704,12 @@ func (pm PageMetadata) query() (string, error) { if pm.To != 0 { q.Add("to", strconv.FormatInt(pm.To, 10)) } + if !pm.CreatedFrom.IsZero() { + q.Add("created_from", pm.CreatedFrom.Format(time.RFC3339)) + } + if !pm.CreatedTo.IsZero() { + q.Add("created_to", pm.CreatedTo.Format(time.RFC3339)) + } q.Add("with_attributes", strconv.FormatBool(pm.WithAttributes)) q.Add("with_metadata", strconv.FormatBool(pm.WithMetadata)) diff --git a/pkg/sdk/users_test.go b/pkg/sdk/users_test.go index ee56dd40f..603e794f4 100644 --- a/pkg/sdk/users_test.go +++ b/pkg/sdk/users_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" api "github.com/absmach/supermq/api/http" @@ -509,6 +510,98 @@ func TestListUsers(t *testing.T) { }, err: nil, }, + { + desc: "list users with CreatedFrom", + token: validToken, + pageMeta: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + svcReq: users.Page{ + Offset: offset, + Limit: limit, + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + Order: api.DefOrder, + Dir: api.DefDir, + }, + svcRes: users.UsersPage{ + Page: users.Page{ + Total: uint64(len(cls[offset:limit])), + }, + Users: convertUsers(cls[offset:limit]), + }, + svcErr: nil, + response: sdk.UsersPage{ + PageRes: sdk.PageRes{ + Total: uint64(len(cls[offset:limit])), + }, + Users: cls[offset:limit], + }, + err: nil, + }, + { + desc: "list users with CreatedTo", + token: validToken, + pageMeta: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + CreatedTo: time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), + }, + svcReq: users.Page{ + Offset: offset, + Limit: limit, + CreatedTo: time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), + Order: api.DefOrder, + Dir: api.DefDir, + }, + svcRes: users.UsersPage{ + Page: users.Page{ + Total: uint64(len(cls[offset:limit])), + }, + Users: convertUsers(cls[offset:limit]), + }, + svcErr: nil, + response: sdk.UsersPage{ + PageRes: sdk.PageRes{ + Total: uint64(len(cls[offset:limit])), + }, + Users: cls[offset:limit], + }, + err: nil, + }, + { + desc: "list users with both CreatedFrom and CreatedTo", + token: validToken, + pageMeta: sdk.PageMetadata{ + Offset: offset, + Limit: limit, + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedTo: time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), + }, + svcReq: users.Page{ + Offset: offset, + Limit: limit, + CreatedFrom: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedTo: time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), + Order: api.DefOrder, + Dir: api.DefDir, + }, + svcRes: users.UsersPage{ + Page: users.Page{ + Total: 2, + }, + Users: []users.User{convertUser(cls[10]), convertUser(cls[20])}, + }, + svcErr: nil, + response: sdk.UsersPage{ + PageRes: sdk.PageRes{ + Total: 2, + }, + Users: []sdk.User{cls[10], cls[20]}, + }, + err: nil, + }, { desc: "list users with request that can't be marshalled", token: validToken, diff --git a/users/api/endpoint_test.go b/users/api/endpoint_test.go index fe8211f54..4a5d120be 100644 --- a/users/api/endpoint_test.go +++ b/users/api/endpoint_test.go @@ -13,6 +13,7 @@ import ( "regexp" "strings" "testing" + "time" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" api "github.com/absmach/supermq/api/http" @@ -56,6 +57,7 @@ var ( testReferer = "http://localhost" domainID = testsutil.GenerateUUID(&testing.T{}) verifiedSession = smqauthn.Session{UserID: validID, DomainID: domainID, Verified: true} + validTimeStamp = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) ) const contentType = "application/json" @@ -846,6 +848,125 @@ func TestListUsers(t *testing.T) { authnRes: verifiedSession, err: apiutil.ErrInvalidQueryParams, }, + { + desc: "list users with created_from", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z", + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Dir: api.DefDir, + Order: api.DefOrder, + CreatedFrom: validTimeStamp, + }, + listUsersResponse: users.UsersPage{ + Page: users.Page{ + Total: 1, + }, + Users: []users.User{user}, + }, + status: http.StatusOK, + authnRes: verifiedSession, + err: nil, + }, + { + desc: "list users with created_to", + token: validToken, + query: "created_to=2024-01-01T00:00:00Z", + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + CreatedTo: validTimeStamp, + }, + listUsersResponse: users.UsersPage{ + Page: users.Page{ + Total: 1, + }, + Users: []users.User{user}, + }, + status: http.StatusOK, + authnRes: verifiedSession, + err: nil, + }, + { + desc: "list users with both created_from and created_to", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z&created_to=2024-01-01T00:00:00Z", + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + CreatedFrom: validTimeStamp, + CreatedTo: validTimeStamp, + }, + listUsersResponse: users.UsersPage{ + Page: users.Page{ + Total: 1, + }, + Users: []users.User{user}, + }, + status: http.StatusOK, + authnRes: verifiedSession, + err: nil, + }, + { + desc: "list users with invalid created_from format", + token: validToken, + query: "created_from=invalid-date", + status: http.StatusBadRequest, + authnRes: verifiedSession, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list users with invalid created_to format", + token: validToken, + query: "created_to=invalid-date", + status: http.StatusBadRequest, + authnRes: verifiedSession, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list users with duplicate created_from", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z&created_from=2024-01-02T00:00:00Z", + status: http.StatusBadRequest, + authnRes: verifiedSession, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list users with duplicate created_to", + token: validToken, + query: "created_to=2024-12-31T23:59:59Z&created_to=2024-12-30T23:59:59Z", + status: http.StatusBadRequest, + authnRes: verifiedSession, + err: apiutil.ErrInvalidQueryParams, + }, + { + desc: "list users with created_from and others", + token: validToken, + query: "created_from=2024-01-01T00:00:00Z&status=enabled&limit=10", + pageMeta: users.Page{ + Offset: 0, + Limit: 10, + Order: api.DefOrder, + Dir: api.DefDir, + Status: users.EnabledStatus, + CreatedFrom: validTimeStamp, + }, + listUsersResponse: users.UsersPage{ + Page: users.Page{ + Total: 1, + Limit: 10, + }, + Users: []users.User{user}, + }, + status: http.StatusOK, + authnRes: verifiedSession, + err: nil, + }, } for _, tc := range cases { diff --git a/users/api/endpoints.go b/users/api/endpoints.go index 48e57a204..a53bd4771 100644 --- a/users/api/endpoints.go +++ b/users/api/endpoints.go @@ -122,19 +122,21 @@ func listUsersEndpoint(svc users.Service) endpoint.Endpoint { } pm := users.Page{ - Status: req.status, - Offset: req.offset, - Limit: req.limit, - OnlyTotal: req.onlyTotal, - Username: req.userName, - Tags: req.tags, - Metadata: req.metadata, - FirstName: req.firstName, - LastName: req.lastName, - Email: req.email, - Order: req.order, - Dir: req.dir, - Id: req.id, + Status: req.status, + Offset: req.offset, + Limit: req.limit, + OnlyTotal: req.onlyTotal, + Username: req.userName, + Tags: req.tags, + Metadata: req.metadata, + FirstName: req.firstName, + LastName: req.lastName, + Email: req.email, + Order: req.order, + Dir: req.dir, + Id: req.id, + CreatedFrom: req.createdFrom, + CreatedTo: req.createdTo, } page, err := svc.ListUsers(ctx, session, pm) diff --git a/users/api/requests.go b/users/api/requests.go index 7a0205f48..bdca2627a 100644 --- a/users/api/requests.go +++ b/users/api/requests.go @@ -5,6 +5,7 @@ package api import ( "net/url" + "time" api "github.com/absmach/supermq/api/http" apiutil "github.com/absmach/supermq/api/http/util" @@ -94,19 +95,21 @@ func (req viewUserReq) validate() error { } type listUsersReq struct { - status users.Status - offset uint64 - limit uint64 - onlyTotal bool - userName string - tags users.TagsQuery - firstName string - lastName string - email string - metadata users.Metadata - order string - dir string - id string + status users.Status + offset uint64 + limit uint64 + onlyTotal bool + userName string + tags users.TagsQuery + firstName string + lastName string + email string + metadata users.Metadata + order string + dir string + id string + createdFrom time.Time + createdTo time.Time } func (req listUsersReq) validate() error { diff --git a/users/api/users.go b/users/api/users.go index a9b0773a7..0a4bc76b5 100644 --- a/users/api/users.go +++ b/users/api/users.go @@ -10,6 +10,7 @@ import ( "net/http" "regexp" "strings" + "time" "github.com/absmach/supermq" grpcTokenV1 "github.com/absmach/supermq/api/grpc/token/v1" @@ -317,20 +318,43 @@ func decodeListUsers(_ context.Context, r *http.Request) (any, error) { return nil, errors.Wrap(apiutil.ErrValidation, err) } + cfrom, err := apiutil.ReadStringQuery(r, "created_from", "") + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + cto, err := apiutil.ReadStringQuery(r, "created_to", "") + if err != nil { + return nil, errors.Wrap(apiutil.ErrValidation, err) + } + + var createdFrom, createdTo time.Time + if cfrom != "" { + if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil { + return nil, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + if cto != "" { + if createdTo, err = time.Parse(time.RFC3339, cto); err != nil { + return nil, errors.Wrap(apiutil.ErrInvalidQueryParams, err) + } + } + req := listUsersReq{ - status: st, - offset: o, - limit: l, - onlyTotal: ot, - metadata: m, - userName: n, - firstName: i, - lastName: f, - tags: tq, - order: order, - dir: dir, - id: id, - email: d, + status: st, + offset: o, + limit: l, + onlyTotal: ot, + metadata: m, + userName: n, + firstName: i, + lastName: f, + tags: tq, + order: order, + dir: dir, + id: id, + email: d, + createdFrom: createdFrom, + createdTo: createdTo, } return req, nil diff --git a/users/postgres/users.go b/users/postgres/users.go index d0c548e7f..31b6a3c25 100644 --- a/users/postgres/users.go +++ b/users/postgres/users.go @@ -621,19 +621,21 @@ func ToUser(dbu DBUser) (users.User, error) { } type DBUsersPage struct { - Total uint64 `db:"total"` - Limit uint64 `db:"limit"` - Offset uint64 `db:"offset"` - FirstName string `db:"first_name"` - LastName string `db:"last_name"` - Username string `db:"username"` - Id string `db:"id"` - Email string `db:"email"` - Metadata []byte `db:"metadata"` - Tags pgtype.TextArray `db:"tags"` - GroupID string `db:"group_id"` - Role users.Role `db:"role"` - Status users.Status `db:"status"` + Total uint64 `db:"total"` + Limit uint64 `db:"limit"` + Offset uint64 `db:"offset"` + FirstName string `db:"first_name"` + LastName string `db:"last_name"` + Username string `db:"username"` + Id string `db:"id"` + Email string `db:"email"` + Metadata []byte `db:"metadata"` + Tags pgtype.TextArray `db:"tags"` + GroupID string `db:"group_id"` + Role users.Role `db:"role"` + Status users.Status `db:"status"` + CreatedFrom time.Time `db:"created_from"` + CreatedTo time.Time `db:"created_to"` } func ToDBUsersPage(pm users.Page) (DBUsersPage, error) { @@ -648,18 +650,20 @@ func ToDBUsersPage(pm users.Page) (DBUsersPage, error) { } return DBUsersPage{ - FirstName: pm.FirstName, - LastName: pm.LastName, - Username: pm.Username, - Email: pm.Email, - Id: pm.Id, - Metadata: data, - Total: pm.Total, - Offset: pm.Offset, - Limit: pm.Limit, - Status: pm.Status, - Tags: tags, - Role: pm.Role, + FirstName: pm.FirstName, + LastName: pm.LastName, + Username: pm.Username, + Email: pm.Email, + Id: pm.Id, + Metadata: data, + Total: pm.Total, + Offset: pm.Offset, + Limit: pm.Limit, + Status: pm.Status, + Tags: tags, + Role: pm.Role, + CreatedFrom: pm.CreatedFrom, + CreatedTo: pm.CreatedTo, }, nil } @@ -694,13 +698,18 @@ func PageQuery(pm users.Page) (string, error) { if len(pm.Metadata) > 0 { query = append(query, "metadata @> :metadata") } - if len(pm.IDs) != 0 { query = append(query, fmt.Sprintf("id IN ('%s')", strings.Join(pm.IDs, "','"))) } if pm.Status != users.AllStatus { query = append(query, "u.status = :status") } + if !pm.CreatedFrom.IsZero() { + query = append(query, "created_at >= :created_from") + } + if !pm.CreatedTo.IsZero() { + query = append(query, "created_at <= :created_to") + } var emq string if len(query) > 0 { diff --git a/users/postgres/users_test.go b/users/postgres/users_test.go index d909f4a7c..210598b81 100644 --- a/users/postgres/users_test.go +++ b/users/postgres/users_test.go @@ -1034,6 +1034,90 @@ func TestRetrieveAll(t *testing.T) { }, err: nil, }, + { + desc: "retrieve users created from specific time", + pageMeta: users.Page{ + CreatedFrom: baseTime.Add(50 * time.Millisecond), + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + Order: "created_at", + Dir: ascDir, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 150, + Offset: 0, + Limit: 200, + }, + Users: items[50:200], + }, + err: nil, + }, + { + desc: "retrieve users created to specific time", + pageMeta: users.Page{ + CreatedTo: baseTime.Add(49 * time.Millisecond), + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + Order: "created_at", + Dir: ascDir, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 50, + Offset: 0, + Limit: 200, + }, + Users: items[0:50], + }, + err: nil, + }, + { + desc: "retrieve users created within time range", + pageMeta: users.Page{ + CreatedFrom: baseTime.Add(50 * time.Millisecond), + CreatedTo: baseTime.Add(99 * time.Millisecond), + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + Order: "created_at", + Dir: ascDir, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 50, + Offset: 0, + Limit: 200, + }, + Users: items[50:100], + }, + err: nil, + }, + { + desc: "retrieve users with time range outside of all records", + pageMeta: users.Page{ + CreatedFrom: baseTime.Add(300 * time.Millisecond), + CreatedTo: baseTime.Add(400 * time.Millisecond), + Offset: 0, + Limit: 200, + Role: users.AllRole, + Status: users.AllStatus, + }, + page: users.UsersPage{ + Page: users.Page{ + Total: 0, + Offset: 0, + Limit: 200, + }, + Users: []users.User{}, + }, + err: nil, + }, } for _, tc := range cases { diff --git a/users/users.go b/users/users.go index bbd6889c6..dbfdf1b25 100644 --- a/users/users.go +++ b/users/users.go @@ -176,26 +176,28 @@ func ToTagsQuery(s string) TagsQuery { // Page contains page metadata that helps navigation. type Page struct { - Total uint64 `json:"total"` - Offset uint64 `json:"offset"` - Limit uint64 `json:"limit"` - OnlyTotal bool `json:"only_total"` - Id string `json:"id,omitempty"` - Order string `json:"order,omitempty"` - Dir string `json:"dir,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Domain string `json:"domain,omitempty"` - Tags TagsQuery `json:"tag,omitempty"` - Permission string `json:"permission,omitempty"` - Status Status `json:"status,omitempty"` - IDs []string `json:"ids,omitempty"` - Role Role `json:"-"` - ListPerms bool `json:"-"` - Username string `json:"username,omitempty"` - FirstName string `json:"first_name,omitempty"` - LastName string `json:"last_name,omitempty"` - Email string `json:"email,omitempty"` - Verified bool `json:"verified,omitempty"` + Total uint64 `json:"total"` + Offset uint64 `json:"offset"` + Limit uint64 `json:"limit"` + OnlyTotal bool `json:"only_total"` + Id string `json:"id,omitempty"` + Order string `json:"order,omitempty"` + Dir string `json:"dir,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Domain string `json:"domain,omitempty"` + Tags TagsQuery `json:"tag,omitempty"` + Permission string `json:"permission,omitempty"` + Status Status `json:"status,omitempty"` + IDs []string `json:"ids,omitempty"` + Role Role `json:"-"` + ListPerms bool `json:"-"` + Username string `json:"username,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Email string `json:"email,omitempty"` + Verified bool `json:"verified,omitempty"` + CreatedFrom time.Time `json:"created_from,omitempty"` + CreatedTo time.Time `json:"created_to,omitempty"` } // Service specifies an API that must be fullfiled by the domain service