diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04acab6..27ee941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,9 @@ jobs: strategy: fail-fast: true matrix: - go: ['1.20', '1.21'] + go: ['1.21'] + # Disabling 1.20 because I'm using slices and maybe slog and don't really want to wait. +# go: ['1.20', '1.21'] steps: - name: Check out code uses: actions/checkout@v4 diff --git a/assets/migrations/000003_setup_tables.down.sql b/assets/migrations/000003_setup_tables.down.sql index 45e4c68..180ab8a 100644 --- a/assets/migrations/000003_setup_tables.down.sql +++ b/assets/migrations/000003_setup_tables.down.sql @@ -1 +1,2 @@ +DROP TABLE MEMBERS; DROP TABLE ROTAS; diff --git a/assets/migrations/000003_setup_tables.up.sql b/assets/migrations/000003_setup_tables.up.sql index bd1c222..5956acc 100644 --- a/assets/migrations/000003_setup_tables.up.sql +++ b/assets/migrations/000003_setup_tables.up.sql @@ -16,3 +16,27 @@ CREATE TRIGGER rotas_updated_at_trigger ON ROTAS FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +CREATE TABLE MEMBERS +( + ID TEXT PRIMARY KEY DEFAULT ('RM' || generate_uid(14)), + ROTA_ID TEXT NOT NULL, + USER_ID TEXT NOT NULL, + METADATA JSONB NOT NULL, + CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(), + UPDATED_AT TIMESTAMP NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_rota_id_on_member + FOREIGN KEY (ROTA_ID) + REFERENCES ROTAS (ID) + ON DELETE CASCADE +); + +CREATE UNIQUE INDEX idx_unique_user_within_rota ON MEMBERS (ROTA_ID, USER_ID); +CREATE INDEX idx_user_id_on_members ON MEMBERS (USER_ID); + +CREATE TRIGGER members_updated_at_trigger + BEFORE UPDATE + ON MEMBERS + FOR EACH ROW + EXECUTE PROCEDURE trigger_set_timestamp(); \ No newline at end of file diff --git a/assets/queries.sql b/assets/queries.sql index ab230e9..4242523 100644 --- a/assets/queries.sql +++ b/assets/queries.sql @@ -5,7 +5,7 @@ WHERE ID = $1; -- name: ListRotasByChannel :many SELECT ROTAS.* -from ROTAS +FROM ROTAS WHERE ROTAS.CHANNEL_ID = $1 AND ROTAS.TEAM_ID = $2; @@ -18,3 +18,15 @@ UPDATE ROTAS SET NAME = $1, METADATA = $2 WHERE ID = $3 RETURNING ID; + +-- name: saveMember :one +INSERT INTO MEMBERS (ROTA_ID, USER_ID, METADATA) +VALUES ($1, $2, $3) RETURNING ID; + +-- name: deleteMember :exec +DELETE FROM MEMBERS WHERE USER_ID = $1; + +-- name: ListUserIDsByRotaID :many +SELECT MEMBERS.USER_ID +FROM MEMBERS +WHERE MEMBERS.ROTA_ID = $1; \ No newline at end of file diff --git a/assets/structure.sql b/assets/structure.sql index 250a54f..5999385 100644 --- a/assets/structure.sql +++ b/assets/structure.sql @@ -76,6 +76,22 @@ SET default_tablespace = ''; SET default_table_access_method = heap; +-- +-- Name: members; Type: TABLE; Schema: public; Owner: rotabot +-- + +CREATE TABLE public.members ( + id text DEFAULT ('RM'::text || public.generate_uid(14)) NOT NULL, + rota_id text NOT NULL, + user_id text NOT NULL, + metadata jsonb NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE public.members OWNER TO rotabot; + -- -- Name: rotas; Type: TABLE; Schema: public; Owner: rotabot -- @@ -105,6 +121,14 @@ CREATE TABLE public.schema_migrations ( ALTER TABLE public.schema_migrations OWNER TO rotabot; +-- +-- Data for Name: members; Type: TABLE DATA; Schema: public; Owner: rotabot +-- + +COPY public.members (id, rota_id, user_id, metadata, created_at, updated_at) FROM stdin; +\. + + -- -- Data for Name: rotas; Type: TABLE DATA; Schema: public; Owner: rotabot -- @@ -122,6 +146,14 @@ COPY public.schema_migrations (version, dirty) FROM stdin; \. +-- +-- Name: members members_pkey; Type: CONSTRAINT; Schema: public; Owner: rotabot +-- + +ALTER TABLE ONLY public.members + ADD CONSTRAINT members_pkey PRIMARY KEY (id); + + -- -- Name: rotas rotas_pkey; Type: CONSTRAINT; Schema: public; Owner: rotabot -- @@ -145,6 +177,27 @@ ALTER TABLE ONLY public.schema_migrations CREATE UNIQUE INDEX idx_unique_rota_within_team_and_channel ON public.rotas USING btree (name, channel_id, team_id); +-- +-- Name: idx_unique_user_within_rota; Type: INDEX; Schema: public; Owner: rotabot +-- + +CREATE UNIQUE INDEX idx_unique_user_within_rota ON public.members USING btree (rota_id, user_id); + + +-- +-- Name: idx_user_id_on_members; Type: INDEX; Schema: public; Owner: rotabot +-- + +CREATE INDEX idx_user_id_on_members ON public.members USING btree (user_id); + + +-- +-- Name: members members_updated_at_trigger; Type: TRIGGER; Schema: public; Owner: rotabot +-- + +CREATE TRIGGER members_updated_at_trigger BEFORE UPDATE ON public.members FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamp(); + + -- -- Name: rotas rotas_updated_at_trigger; Type: TRIGGER; Schema: public; Owner: rotabot -- @@ -152,6 +205,14 @@ CREATE UNIQUE INDEX idx_unique_rota_within_team_and_channel ON public.rotas USIN CREATE TRIGGER rotas_updated_at_trigger BEFORE UPDATE ON public.rotas FOR EACH ROW EXECUTE FUNCTION public.trigger_set_timestamp(); +-- +-- Name: members fk_rota_id_on_member; Type: FK CONSTRAINT; Schema: public; Owner: rotabot +-- + +ALTER TABLE ONLY public.members + ADD CONSTRAINT fk_rota_id_on_member FOREIGN KEY (rota_id) REFERENCES public.rotas(id) ON DELETE CASCADE; + + -- -- PostgreSQL database dump complete -- diff --git a/internal/internal_suite_test.go b/internal/internal_suite_test.go new file mode 100644 index 0000000..c6dee9c --- /dev/null +++ b/internal/internal_suite_test.go @@ -0,0 +1,13 @@ +package internal + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDb(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal Suite") +} diff --git a/internal/unique.go b/internal/unique.go new file mode 100644 index 0000000..988f739 --- /dev/null +++ b/internal/unique.go @@ -0,0 +1,16 @@ +package internal + +func Unique[T comparable](slice []T) []T { + // create a map with all the values as key + uniqMap := make(map[T]struct{}) + for _, v := range slice { + uniqMap[v] = struct{}{} + } + + // turn the map keys into a slice + uniqSlice := make([]T, 0, len(uniqMap)) + for v := range uniqMap { + uniqSlice = append(uniqSlice, v) + } + return uniqSlice +} diff --git a/internal/unique_test.go b/internal/unique_test.go new file mode 100644 index 0000000..aae0476 --- /dev/null +++ b/internal/unique_test.go @@ -0,0 +1,43 @@ +package internal + +import ( + "sort" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Unique", func() { + It("should return unique strings", func() { + arr := []string{ + "mice", + "mice", + "mice", + "mice", + "mice", + "mice", + "mice", + "toad", + "toad", + "mice", + } + result := Unique(arr) + sort.Strings(result) + Expect(result).To(Equal([]string{"mice", "toad"})) + }) + + It("should return unique numbers", func() { + arr := []int{ + 1, + 1, + 2, + 3, + 1, + 2, + 3, + } + result := Unique(arr) + sort.Ints(result) + Expect(result).To(Equal([]int{1, 2, 3})) + }) +}) diff --git a/lib/db/mock_db/db.go b/lib/db/mock_db/db.go index d59fc58..2633eb8 100644 --- a/lib/db/mock_db/db.go +++ b/lib/db/mock_db/db.go @@ -83,3 +83,32 @@ func (mr *MockRepositoryMockRecorder) ListRotasByChannel(arg0, arg1 any) *gomock mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRotasByChannel", reflect.TypeOf((*MockRepository)(nil).ListRotasByChannel), arg0, arg1) } + +// ListUserIDsByRotaID mocks base method. +func (m *MockRepository) ListUserIDsByRotaID(arg0 context.Context, arg1 string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserIDsByRotaID", arg0, arg1) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserIDsByRotaID indicates an expected call of ListUserIDsByRotaID. +func (mr *MockRepositoryMockRecorder) ListUserIDsByRotaID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserIDsByRotaID", reflect.TypeOf((*MockRepository)(nil).ListUserIDsByRotaID), arg0, arg1) +} + +// UpdateRotaMembers mocks base method. +func (m *MockRepository) UpdateRotaMembers(arg0 context.Context, arg1 []db.Member) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateRotaMembers", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateRotaMembers indicates an expected call of UpdateRotaMembers. +func (mr *MockRepositoryMockRecorder) UpdateRotaMembers(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateRotaMembers", reflect.TypeOf((*MockRepository)(nil).UpdateRotaMembers), arg0, arg1) +} diff --git a/lib/db/models.go b/lib/db/models.go index d03a2eb..e53fab9 100644 --- a/lib/db/models.go +++ b/lib/db/models.go @@ -8,6 +8,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Member struct { + ID string `json:"id"` + RotaID string `json:"rota_id"` + UserID string `json:"user_id"` + Metadata MemberMetadata `json:"metadata"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + type Rota struct { ID string `json:"id"` TeamID string `json:"team_id"` diff --git a/lib/db/queries.go b/lib/db/queries.go index 3967fcb..f10830f 100644 --- a/lib/db/queries.go +++ b/lib/db/queries.go @@ -3,6 +3,9 @@ package db import ( "context" "errors" + "slices" + + "github.com/rotabot-io/rotabot/internal" "github.com/jackc/pgx/v5/pgconn" "github.com/rotabot-io/rotabot/lib/zapctx" @@ -36,15 +39,79 @@ func (q *Queries) CreateOrUpdateRota(ctx context.Context, p CreateOrUpdateRotaPa }) } if err != nil { - var pgError *pgconn.PgError - if errors.As(err, &pgError) { - switch pgError.Code { - case "23505": - return "", ErrAlreadyExists - } - } + err = mapError(err) l.Error("failed to save rota", zap.Error(err)) return "", err } return rotaId, nil } + +func (q *Queries) UpdateRotaMembers(ctx context.Context, members []Member) error { + l := zapctx.Logger(ctx) + if len(members) == 0 { + l.Warn("no_members_to_update") + return nil + } + rotas := []string{} + for _, m := range members { + rotas = append(rotas, m.RotaID) + } + if len(internal.Unique(rotas)) > 1 { + return ErrMembersBelongToDifferentRotas + } + rotaId := rotas[0] + e, err := q.ListUserIDsByRotaID(ctx, rotaId) + if err != nil { + l.Error("unable_to_fetch_existing_members", zap.Error(err)) + return err + } + for _, userId := range e { + // Check existing member needs to be deleted + if inx := slices.IndexFunc(members, func(member Member) bool { return member.UserID == userId }); inx == -1 { + err = q.deleteMember(ctx, userId) + if err != nil { + l.Error("unable_to_delete_member", + zap.Error(err), + zap.String("user_id", userId), + zap.Strings("existing", e), + ) + return err + } + } + } + for _, m := range members { + // Check if desired member already exist before adding + if inx := slices.IndexFunc(e, func(userId string) bool { return m.UserID == userId }); inx == -1 { + _, err = q.saveMember(ctx, saveMemberParams{ + RotaID: rotaId, + UserID: m.UserID, + Metadata: m.Metadata, + }) + } + if err != nil { + err = mapError(err) + l.Error("unable_to_add_member", + zap.Error(err), + zap.String("user_id", m.UserID), + zap.Strings("existing", e), + ) + return err + } + // Avoid trying to add the same user_id twice within the same request + e = append(e, m.UserID) + } + return nil +} + +func mapError(err error) error { + var pgError *pgconn.PgError + if errors.As(err, &pgError) { + // The magic list of errors can be found here + // https://www.postgresql.org/docs/current/errcodes-appendix.html + switch pgError.Code { + case "23505": + return ErrAlreadyExists + } + } + return err +} diff --git a/lib/db/queries.sql.go b/lib/db/queries.sql.go index 1e1755c..4dac424 100644 --- a/lib/db/queries.sql.go +++ b/lib/db/queries.sql.go @@ -32,7 +32,7 @@ func (q *Queries) FindRotaByID(ctx context.Context, id string) (Rota, error) { const listRotasByChannel = `-- name: ListRotasByChannel :many SELECT rotas.id, rotas.team_id, rotas.channel_id, rotas.name, rotas.metadata, rotas.created_at, rotas.updated_at -from ROTAS +FROM ROTAS WHERE ROTAS.CHANNEL_ID = $1 AND ROTAS.TEAM_ID = $2 ` @@ -70,6 +70,59 @@ func (q *Queries) ListRotasByChannel(ctx context.Context, arg ListRotasByChannel return items, nil } +const listUserIDsByRotaID = `-- name: ListUserIDsByRotaID :many +SELECT MEMBERS.USER_ID +FROM MEMBERS +WHERE MEMBERS.ROTA_ID = $1 +` + +func (q *Queries) ListUserIDsByRotaID(ctx context.Context, rotaID string) ([]string, error) { + rows, err := q.db.Query(ctx, listUserIDsByRotaID, rotaID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var user_id string + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteMember = `-- name: deleteMember :exec +DELETE FROM MEMBERS WHERE USER_ID = $1 +` + +func (q *Queries) deleteMember(ctx context.Context, userID string) error { + _, err := q.db.Exec(ctx, deleteMember, userID) + return err +} + +const saveMember = `-- name: saveMember :one +INSERT INTO MEMBERS (ROTA_ID, USER_ID, METADATA) +VALUES ($1, $2, $3) RETURNING ID +` + +type saveMemberParams struct { + RotaID string `json:"rota_id"` + UserID string `json:"user_id"` + Metadata MemberMetadata `json:"metadata"` +} + +func (q *Queries) saveMember(ctx context.Context, arg saveMemberParams) (string, error) { + row := q.db.QueryRow(ctx, saveMember, arg.RotaID, arg.UserID, arg.Metadata) + var id string + err := row.Scan(&id) + return id, err +} + const saveRota = `-- name: saveRota :one INSERT INTO ROTAS (TEAM_ID, CHANNEL_ID, NAME, METADATA) VALUES ($1, $2, $3, $4) RETURNING ID diff --git a/lib/db/queries_test.go b/lib/db/queries_test.go index 5b1b55b..8ec6a57 100644 --- a/lib/db/queries_test.go +++ b/lib/db/queries_test.go @@ -4,11 +4,13 @@ import ( "context" "errors" "path/filepath" + "sort" + + "github.com/jackc/pgx/v5/pgconn" "github.com/testcontainers/testcontainers-go" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/rotabot-io/rotabot/internal" @@ -20,6 +22,7 @@ var _ = Describe("Rotas", func() { var q *Queries BeforeEach(func() { + var err error ctx = context.Background() container, err := internal.RunContainer(ctx, @@ -33,7 +36,10 @@ var _ = Describe("Rotas", func() { conn, err := pgx.Connect(ctx, dbUrl) Expect(err).ToNot(HaveOccurred()) - q = New(conn) + + tx, err := conn.Begin(ctx) + Expect(err).ToNot(HaveOccurred()) + q = New(conn).WithTx(tx) DeferCleanup(func() { _ = container.Terminate(ctx) @@ -50,6 +56,10 @@ var _ = Describe("Rotas", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeEmpty()) + + ms, err := q.ListUserIDsByRotaID(ctx, id) + Expect(err).ToNot(HaveOccurred()) + Expect(ms).To(BeEmpty()) }) It("Should fail to create two identical rotas", func() { @@ -127,6 +137,148 @@ var _ = Describe("Rotas", func() { }) }) + Describe("UpdateRotaMembers", func() { + var rotaId string + + BeforeEach(func() { + var err error + rotaId, err = q.CreateOrUpdateRota(ctx, CreateOrUpdateRotaParams{ + ChannelID: "foo", + TeamID: "bar", + Name: "baz", + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("fails if i try to update members from different rotas", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: "1234", + UserID: "12345", + Metadata: MemberMetadata{}, + }, + { + RotaID: "4321", + UserID: "54321", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ErrMembersBelongToDifferentRotas)) + }) + + It("does not fail if i send no rotas", func() { + err := q.UpdateRotaMembers(ctx, []Member{}) + Expect(err).ToNot(HaveOccurred()) + }) + + When("there's no existing rota members", func() { + It("Creates new rota member from the list sent", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Creates a list of rota members", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + { + RotaID: rotaId, + UserID: "67891", + Metadata: MemberMetadata{}, + }, + { + RotaID: rotaId, + UserID: "random", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Creates only one member if i send a list with duplicates", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + members, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(members)).To(Equal(1)) + }) + }) + + When("Rota already has rota members", func() { + BeforeEach(func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Should delete the existing ones if I don't send it on the new list", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "98765", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + members, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(members)).To(Equal(1)) + Expect(members[0]).To(Equal("98765")) + }) + + It("Should not delete existing members if the new list contains it", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "98765", + Metadata: MemberMetadata{}, + }, + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + members, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(members)).To(Equal(2)) + sort.Strings(members) + Expect(members[0]).To(Equal("12345")) + Expect(members[1]).To(Equal("98765")) + }) + }) + }) + Describe("ListRotasByChannel", func() { var ( channelID string @@ -224,10 +376,6 @@ var _ = Describe("Rotas", func() { var pgError *pgconn.PgError Expect(errors.As(err, &pgError)).To(BeTrue()) Expect(pgError.Code).To(Equal("23505")) - - rotas, err := q.ListRotasByChannel(ctx, ListRotasByChannelParams{ChannelID: channelID, TeamID: teamID}) - Expect(err).ToNot(HaveOccurred()) - Expect(rotas).To(HaveLen(1)) }) }) @@ -284,4 +432,96 @@ var _ = Describe("Rotas", func() { }) }) }) + + Describe("DeleteMember", func() { + var rotaId string + + BeforeEach(func() { + var err error + rotaId, err = q.CreateOrUpdateRota(ctx, CreateOrUpdateRotaParams{ + ChannelID: "foo", + TeamID: "bar", + Name: "baz", + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should delete member when it exist", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + err = q.deleteMember(ctx, "12345") + Expect(err).ToNot(HaveOccurred()) + + list, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(list)).To(Equal(0)) + }) + + It("should not fail delete member when it does not exist", func() { + err := q.deleteMember(ctx, "12345") + Expect(err).ToNot(HaveOccurred()) + + list, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(list)).To(Equal(0)) + }) + }) + + Describe("ListUserIDsByRotaID", func() { + var rotaId string + + BeforeEach(func() { + var err error + rotaId, err = q.CreateOrUpdateRota(ctx, CreateOrUpdateRotaParams{ + ChannelID: "foo", + TeamID: "bar", + Name: "baz", + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("find members when they exist", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + list, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(list)).To(Equal(1)) + Expect(list[0]).To(Equal("12345")) + }) + + It("should not find any member when it does not exist", func() { + list, err := q.ListUserIDsByRotaID(ctx, rotaId) + Expect(err).ToNot(HaveOccurred()) + Expect(len(list)).To(Equal(0)) + }) + + It("should not find members when they exist on another rota", func() { + err := q.UpdateRotaMembers(ctx, []Member{ + { + RotaID: rotaId, + UserID: "12345", + Metadata: MemberMetadata{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + list, err := q.ListUserIDsByRotaID(ctx, "another_rota") + Expect(err).ToNot(HaveOccurred()) + Expect(len(list)).To(Equal(0)) + }) + }) }) diff --git a/lib/db/repository.go b/lib/db/repository.go index 366bdfb..2f3f4ae 100644 --- a/lib/db/repository.go +++ b/lib/db/repository.go @@ -7,8 +7,9 @@ import ( ) var ( - ErrAlreadyExists = errors.New("resource already exist") - ErrNotFound = errors.New("no rows in result set") + ErrAlreadyExists = errors.New("resource already exist") + ErrNotFound = errors.New("no rows in result set") + ErrMembersBelongToDifferentRotas = errors.New("members must belong to the same rota") ) // RotaSchedule is the type that defines how the members of a rota are scheduled @@ -31,8 +32,12 @@ type RotaMetadata struct { SchedulingType RotaSchedule `json:"scheduling_type"` } +type MemberMetadata struct{} + type Repository interface { CreateOrUpdateRota(ctx context.Context, p CreateOrUpdateRotaParams) (string, error) + UpdateRotaMembers(ctx context.Context, members []Member) error FindRotaByID(ctx context.Context, id string) (Rota, error) ListRotasByChannel(ctx context.Context, args ListRotasByChannelParams) ([]Rota, error) + ListUserIDsByRotaID(ctx context.Context, rotaID string) ([]string, error) } diff --git a/slack/block/input.go b/slack/block/input.go index 7221bfe..b0f7966 100644 --- a/slack/block/input.go +++ b/slack/block/input.go @@ -61,3 +61,24 @@ func staticSelectOption(option StaticSelectOption) *slack.OptionBlockObject { Value: option.Text, } } + +type UserSelect struct { + BlockID string + Label string + UserIDs []string +} + +func NewUserSelect(input UserSelect) *slack.SectionBlock { + return &slack.SectionBlock{ + Type: slack.MBTSection, + BlockID: input.BlockID, + Text: NewDefaultText(input.Label), + Accessory: slack.NewAccessory( + &slack.MultiSelectBlockElement{ + Type: slack.MultiOptTypeUser, + InitialUsers: input.UserIDs, + ActionID: input.BlockID, + }, + ), + } +} diff --git a/slack/block/input_test.go b/slack/block/input_test.go index 17ab5fa..86c5574 100644 --- a/slack/block/input_test.go +++ b/slack/block/input_test.go @@ -85,4 +85,48 @@ var _ = Describe("Input", func() { Expect(s.Accessory.SelectElement.Options[0].Value).To(Equal("option1")) }) }) + + Describe("NewUserSelect", func() { + It("generates static select without any user", func() { + s := NewUserSelect(UserSelect{ + BlockID: "blockId", + Label: "label", + UserIDs: []string{}, + }) + + Expect(s.Type).To(Equal(slack.MBTSection)) + Expect(s.BlockID).To(Equal("blockId")) + Expect(s.Text.Type).To(Equal(slack.PlainTextType)) + Expect(s.Text.Text).To(Equal("label")) + + Expect(s.Accessory).ToNot(BeNil()) + Expect(s.Accessory.MultiSelectElement.Type).To(Equal(slack.MultiOptTypeUser)) + Expect(s.Accessory.MultiSelectElement.ActionID).To(Equal("blockId")) + Expect(s.Accessory.MultiSelectElement.InitialUsers).To(BeEmpty()) + }) + + It("generates static select without options", func() { + s := NewUserSelect(UserSelect{ + BlockID: "blockId", + Label: "label", + UserIDs: []string{ + "option1", + "option2", + }, + }) + + Expect(s.Type).To(Equal(slack.MBTSection)) + Expect(s.BlockID).To(Equal("blockId")) + Expect(s.Text.Type).To(Equal(slack.PlainTextType)) + Expect(s.Text.Text).To(Equal("label")) + + Expect(s.Accessory).ToNot(BeNil()) + Expect(s.Accessory.MultiSelectElement.Type).To(Equal(slack.MultiOptTypeUser)) + Expect(s.Accessory.MultiSelectElement.ActionID).To(Equal("blockId")) + Expect(len(s.Accessory.MultiSelectElement.InitialUsers)).To(Equal(2)) + + Expect(s.Accessory.MultiSelectElement.InitialUsers[0]).To(Equal("option1")) + Expect(s.Accessory.MultiSelectElement.InitialUsers[1]).To(Equal("option2")) + }) + }) }) diff --git a/slack/views/home.go b/slack/views/home.go index 8f0ea89..979d33d 100644 --- a/slack/views/home.go +++ b/slack/views/home.go @@ -154,7 +154,9 @@ func (v Home) Render(ctx context.Context, p interface{}) error { func (v Home) handleAddRotaAction(ctx context.Context) (*gen.ActionResponse, error) { l := zapctx.Logger(ctx) - view := SaveRota{} + view := SaveRota{ + Repository: v.Repository, + } view.State = view.DefaultState().(*SaveRotaState) view.State.ChannelID = v.State.ChannelID view.State.TeamID = v.State.TeamID diff --git a/slack/views/home_test.go b/slack/views/home_test.go index 76dca1a..92bb500 100644 --- a/slack/views/home_test.go +++ b/slack/views/home_test.go @@ -2,8 +2,11 @@ package views import ( "context" + "encoding/json" "path/filepath" + "go.uber.org/mock/gomock" + "github.com/testcontainers/testcontainers-go" "github.com/jackc/pgx/v5" @@ -177,31 +180,43 @@ var _ = Describe("Home", func() { It("calls slack api to push add_rota modal", func() { home.State.action = HASaveRota - addRota := &SaveRota{ - State: &SaveRotaState{ - TriggerID: triggerID, - ChannelID: channelID, - TeamID: teamID, - frequency: db.RFWeekly, - schedulingType: db.RSCreated, - }, - } - p, err := addRota.BuildProps(ctx) + sc.EXPECT().PushViewContext(ctx, triggerID, gomock.Cond(func(x any) bool { + view := x.(slack.ModalViewRequest) + + var m Metadata + err := json.Unmarshal([]byte(view.PrivateMetadata), &m) + Expect(err).ToNot(HaveOccurred()) + + Expect(view.CallbackID).To(Equal(string(VTSaveRota))) + Expect(m.ChannelID).To(Equal(channelID)) + return Expect(m.RotaID).To(BeEmpty()) + })).Return(nil, nil).Times(1) + + _, err := home.OnAction(ctx) Expect(err).ToNot(HaveOccurred()) - props := p.(*SaveRotaProps) - - expectedModal := slack.ModalViewRequest{ - Type: slack.VTModal, - Title: props.title, - Blocks: props.blocks, - Close: props.close, - Submit: props.submit, - CallbackID: string(VTSaveRota), - NotifyOnClose: true, - ClearOnClose: true, - PrivateMetadata: "{\"rota_id\":\"\",\"channel_id\":\"CH123\"}", - } - sc.EXPECT().PushViewContext(ctx, triggerID, expectedModal).Return(nil, nil).Times(1) + }) + + It("calls slack api to push update modal", func() { + home.State.action = HASaveRota + id, err := home.Repository.CreateOrUpdateRota(ctx, db.CreateOrUpdateRotaParams{ + Name: "Rota", + ChannelID: channelID, + TeamID: teamID, + }) + Expect(err).ToNot(HaveOccurred()) + + home.State.rotaID = id + sc.EXPECT().PushViewContext(ctx, triggerID, gomock.Cond(func(x any) bool { + view := x.(slack.ModalViewRequest) + + var m Metadata + err := json.Unmarshal([]byte(view.PrivateMetadata), &m) + Expect(err).ToNot(HaveOccurred()) + + Expect(view.CallbackID).To(Equal(string(VTSaveRota))) + Expect(m.ChannelID).To(Equal(channelID)) + return Expect(m.RotaID).To(Equal(id)) + })).Return(nil, nil).Times(1) _, err = home.OnAction(ctx) Expect(err).ToNot(HaveOccurred()) @@ -238,20 +253,18 @@ var _ = Describe("Home", func() { p, err := home.BuildProps(ctx) Expect(err).ToNot(HaveOccurred()) Expect(p).To(BeAssignableToTypeOf(&HomeProps{})) - props := p.(*HomeProps) - expectedView := slack.ModalViewRequest{ - Type: slack.VTModal, - Title: props.title, - Blocks: props.blocks, - CallbackID: string(VTHome), - NotifyOnClose: true, - ClearOnClose: true, - PrivateMetadata: "{\"rota_id\":\"\",\"channel_id\":\"C123\"}", - } - sc.EXPECT(). - OpenViewContext(ctx, home.State.TriggerID, expectedView). - Return(nil, nil).Times(1) + sc.EXPECT().OpenViewContext(ctx, home.State.TriggerID, gomock.Cond(func(x any) bool { + view := x.(slack.ModalViewRequest) + + var m Metadata + err := json.Unmarshal([]byte(view.PrivateMetadata), &m) + Expect(err).ToNot(HaveOccurred()) + + Expect(view.CallbackID).To(Equal(string(VTHome))) + Expect(m.ChannelID).To(Equal("C123")) + return Expect(m.RotaID).To(BeEmpty()) + })).Return(nil, nil).Times(1) err = home.Render(ctx, p) Expect(err).ToNot(HaveOccurred()) diff --git a/slack/views/resolver.go b/slack/views/resolver.go index 28bd625..ca46def 100644 --- a/slack/views/resolver.go +++ b/slack/views/resolver.go @@ -87,6 +87,7 @@ func resolveSaveRota(ctx context.Context, p ResolverParams) (View, error) { view.State.rotaName = values["ROTA_NAME"]["ROTA_NAME"].Value view.State.frequency = db.RotaFrequency(values["ROTA_FREQUENCY"]["ROTA_FREQUENCY"].SelectedOption.Value) view.State.schedulingType = db.RotaSchedule(values["ROTA_TYPE"]["ROTA_TYPE"].SelectedOption.Value) + view.State.userIds = values["ROTA_MEMBERS"]["ROTA_MEMBERS"].SelectedUsers } return view, nil diff --git a/slack/views/save_rota.go b/slack/views/save_rota.go index 15a2398..7a88792 100644 --- a/slack/views/save_rota.go +++ b/slack/views/save_rota.go @@ -33,6 +33,7 @@ type SaveRotaState struct { schedulingType db.RotaSchedule externalID string previousViewID string + userIds []string } type SaveRotaProps struct { @@ -72,6 +73,11 @@ func (v SaveRota) BuildProps(ctx context.Context) (interface{}, error) { schedulingType = rota.Metadata.SchedulingType title = block.NewDefaultText("Update Rota") submit = block.NewDefaultText("Update") + v.State.userIds, err = v.Repository.ListUserIDsByRotaID(ctx, v.State.rotaID) + if err != nil { + l.Error("failed_to_list_members", zap.Error(err)) + return nil, err + } } else { title = block.NewDefaultText("Create Rota") submit = block.NewDefaultText("Create") @@ -106,6 +112,11 @@ func (v SaveRota) BuildProps(ctx context.Context) (interface{}, error) { {Text: string(db.RSRandom)}, }, }), + block.NewUserSelect(block.UserSelect{ + BlockID: "ROTA_MEMBERS", + Label: "Members:", + UserIDs: v.State.userIds, + }), } return &SaveRotaProps{ title: title, @@ -127,7 +138,7 @@ func (v SaveRota) OnClose(ctx context.Context) (*gen.ActionResponse, error) { func (v SaveRota) OnSubmit(ctx context.Context) (*gen.ActionResponse, error) { l := zapctx.Logger(ctx) - id, err := v.Repository.CreateOrUpdateRota(ctx, db.CreateOrUpdateRotaParams{ + rotaId, err := v.Repository.CreateOrUpdateRota(ctx, db.CreateOrUpdateRotaParams{ RotaID: v.State.rotaID, TeamID: v.State.TeamID, ChannelID: v.State.ChannelID, @@ -147,7 +158,19 @@ func (v SaveRota) OnSubmit(ctx context.Context) (*gen.ActionResponse, error) { l.Error("failed_to_create_or_update_rota", zap.Error(err)) return nil, err } - l.Info("saved_rota", zap.String("id", id)) + l.Info("saved_rota", zap.String("rotaId", rotaId)) + members := []db.Member{} + for _, userId := range v.State.userIds { + members = append(members, db.Member{ + UserID: userId, + RotaID: rotaId, + Metadata: db.MemberMetadata{}, + }) + } + if err = v.Repository.UpdateRotaMembers(ctx, members); err != nil { + l.Error("failed_to_update_rota_members", zap.Error(err)) + return nil, err + } h := Home{ Repository: v.Repository, @@ -178,7 +201,7 @@ func (v SaveRota) OnSubmit(ctx context.Context) (*gen.ActionResponse, error) { return nil, err } - bytes, err := json.Marshal(Metadata{RotaID: id, ChannelID: v.State.ChannelID}) + bytes, err := json.Marshal(Metadata{RotaID: rotaId, ChannelID: v.State.ChannelID}) if err != nil { l.Error("failed_to_marshal_metadata", zap.Error(err)) return nil, err diff --git a/slack/views/save_rota_test.go b/slack/views/save_rota_test.go index 4fed2e0..44ef9cd 100644 --- a/slack/views/save_rota_test.go +++ b/slack/views/save_rota_test.go @@ -113,10 +113,11 @@ var _ = Describe("SaveRota", func() { Expect(props.close.Text).To(Equal("Cancel")) Expect(props.submit.Text).To(Equal("Create")) - Expect(props.blocks.BlockSet).To(HaveLen(3)) + Expect(props.blocks.BlockSet).To(HaveLen(4)) Expect(props.blocks.BlockSet[0]).To(BeAssignableToTypeOf(&slack.InputBlock{})) Expect(props.blocks.BlockSet[1]).To(BeAssignableToTypeOf(&slack.SectionBlock{})) Expect(props.blocks.BlockSet[2]).To(BeAssignableToTypeOf(&slack.SectionBlock{})) + Expect(props.blocks.BlockSet[3]).To(BeAssignableToTypeOf(&slack.SectionBlock{})) inputBlock := props.blocks.BlockSet[0].(*slack.InputBlock) Expect(inputBlock.BlockID).To(Equal("ROTA_NAME")) @@ -126,6 +127,9 @@ var _ = Describe("SaveRota", func() { schedulingType := props.blocks.BlockSet[2].(*slack.SectionBlock) Expect(schedulingType.BlockID).To(Equal("ROTA_TYPE")) + + userSelect := props.blocks.BlockSet[3].(*slack.SectionBlock) + Expect(userSelect.BlockID).To(Equal("ROTA_MEMBERS")) }) }) @@ -159,10 +163,11 @@ var _ = Describe("SaveRota", func() { Expect(props.close.Text).To(Equal("Cancel")) Expect(props.submit.Text).To(Equal("Update")) - Expect(props.blocks.BlockSet).To(HaveLen(3)) + Expect(props.blocks.BlockSet).To(HaveLen(4)) Expect(props.blocks.BlockSet[0]).To(BeAssignableToTypeOf(&slack.InputBlock{})) Expect(props.blocks.BlockSet[1]).To(BeAssignableToTypeOf(&slack.SectionBlock{})) Expect(props.blocks.BlockSet[2]).To(BeAssignableToTypeOf(&slack.SectionBlock{})) + Expect(props.blocks.BlockSet[3]).To(BeAssignableToTypeOf(&slack.SectionBlock{})) inputBlock := props.blocks.BlockSet[0].(*slack.InputBlock) Expect(inputBlock.BlockID).To(Equal("ROTA_NAME")) @@ -173,6 +178,9 @@ var _ = Describe("SaveRota", func() { schedulingType := props.blocks.BlockSet[2].(*slack.SectionBlock) Expect(schedulingType.BlockID).To(Equal("ROTA_TYPE")) + + userSelect := props.blocks.BlockSet[3].(*slack.SectionBlock) + Expect(userSelect.BlockID).To(Equal("ROTA_MEMBERS")) }) }) }) @@ -198,7 +206,7 @@ var _ = Describe("SaveRota", func() { }) Describe("OnSubmit", func() { - When("the user creats a rota that already exists", func() { + When("the user creates a rota that already exists", func() { It("returns an error", func() { _, err := repo.CreateOrUpdateRota(ctx, db.CreateOrUpdateRotaParams{ Name: "test", diff --git a/sqlc.yaml b/sqlc.yaml index fd2ea95..f2cc727 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -14,6 +14,9 @@ sql: - column: "rotas.metadata" go_type: type: "RotaMetadata" + - column: "members.metadata" + go_type: + type: "MemberMetadata" database: uri: "postgresql://rotabot@localhost:5432/rotabot?sslmode=disable" rules: