├── .gitignore ├── README.md ├── api.http ├── application ├── available_slots_handler_test.go ├── available_slots_handler_v2_test.go ├── available_slots_projection.go ├── available_slots_projection_v2.go ├── day_archiver_process_manager.go ├── day_archiver_process_manager_test.go ├── overbooking_process_manager.go └── overbooking_process_manager_test.go ├── controllers ├── api_controller.go ├── available_slot_response.go ├── book_slot_request.go ├── cancel_slot_booking_request.go └── schedule_day_request.go ├── docker-compose.yaml ├── domain ├── doctorday │ ├── command_handlers.go │ ├── commands │ │ ├── archive_day_schedule.go │ │ ├── book_slot.go │ │ ├── cancel_day_schedule.go │ │ ├── cancel_slot_booking.go │ │ ├── schedule_day.go │ │ └── schedule_slot.go │ ├── day.go │ ├── day_id.go │ ├── day_repository.go │ ├── day_snapshot.go │ ├── day_snapshot_test.go │ ├── day_test.go │ ├── doctor_id.go │ ├── errors.go │ ├── event_store_day_repo.go │ ├── events │ │ ├── calendar_day_started.go │ │ ├── day_schedule_archived.go │ │ ├── day_schedule_cancelled.go │ │ ├── day_scheduled.go │ │ ├── slot_booked.go │ │ ├── slot_booking_cancelled.go │ │ ├── slot_schedule_cancelled.go │ │ └── slot_scheduled.go │ ├── patient_id.go │ ├── slot.go │ ├── slot_id.go │ ├── slot_status.go │ ├── slots.go │ ├── type_mapping.go │ └── type_mapping_test.go └── readmodel │ ├── archivable_day.go │ ├── archivable_day_repository.go │ ├── available_slot.go │ ├── available_slots_repository.go │ ├── booked_slot.go │ ├── booked_slots_repository.go │ └── scheduled_slot.go ├── eventsourcing ├── aggregate_root.go ├── aggregate_snapshot.go ├── cold_storage.go ├── snapshot_metadata.go └── type_mapper.go ├── go.mod ├── go.sum ├── infrastructure ├── aggregate_store.go ├── aggregate_tests.go ├── command_dispatcher.go ├── command_handler.go ├── command_handler_map.go ├── command_metadata.go ├── es_aggregate_store.go ├── es_checkpoint_store.go ├── es_command_store.go ├── es_event_serde.go ├── es_event_store.go ├── event_handler.go ├── event_metadata.go ├── fake_aggregate_store.go ├── handler_tests.go ├── infrastructur.go ├── inmemory │ ├── archivable_days_repository.go │ └── cold_storage.go ├── mongodb │ ├── archivable_days_repository.go │ ├── available_slot.go │ ├── available_slot_v2.go │ ├── available_slots_repository.go │ ├── available_slots_repository_v2.go │ └── booked_slots_repository.go └── projections │ ├── projector.go │ └── subscription_manager.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Instructions for setting up the project 2 | 3 | **You'll need to have [Go 1.16 or higher](https://go.dev/dl/) and [docker](https://www.docker.com/products/docker-desktop) installed to be able to run the project and tests.** 4 | 5 | 1. Clone this repository `git clone https://github.com/EventStore/training-introduction-go.git` 6 | 2. Make sure you are on the `main` branch 7 | 3. Locate and run `docker-compose.yml` 8 | 4. Run the tests `go test -v ./...` 9 | 5. Run the project `go run .` 10 | 6. Open the project with your IDE 11 | 12 | Any problems please contact training@eventstore.com 13 | -------------------------------------------------------------------------------- /api.http: -------------------------------------------------------------------------------- 1 | ### Schedule a day 2 | POST http://localhost:5001/api/doctor/schedule 3 | Content-Type: application/json 4 | X-CorrelationId: 8ea3fb2b-5acf-4805-bf2c-a56647cc7728 5 | X-CausationId: e07da067-7559-4876-a98a-d95bbeef897c 6 | 7 | { 8 | "date": "2020-08-02", 9 | "doctorId": "7e25fa3c-6123-46bf-9325-9611a88f2696", 10 | "slots": [ 11 | { 12 | "duration": "00:10:00", 13 | "startTime": "14:30:00" 14 | }, 15 | { 16 | "duration": "00:10:00", 17 | "startTime": "14:40:00" 18 | }, 19 | { 20 | "duration": "00:10:00", 21 | "startTime": "14:50:00" 22 | } 23 | ] 24 | } 25 | 26 | ### Get slots available on a date 27 | GET http://localhost:5001/api/slots/2020-08-02/available 28 | 29 | ### Book a slot 30 | POST http://localhost:5001/api/slots/7e25fa3c-6123-46bf-9325-9611a88f2696_2020-08-01/book 31 | Content-Type: application/json 32 | X-CorrelationId: 8ea3fb2b-5acf-4805-bf2c-a56647cc7728 33 | X-CausationId: e07da067-7559-4876-a98a-d95bbeef897c 34 | 35 | { 36 | "slotId": "eee9804d-651a-4f58-abd8-fced2fdb8637", 37 | "patientId": "John Doe" 38 | } 39 | 40 | ### Cancel slot booking 41 | POST https://localhost:5001/api/slots/7e25fa3c-6123-46bf-9325-9611a88f2696_2020-08-01/cancel-booking 42 | Content-Type: application/json 43 | X-CorrelationId: 8ea3fb2b-5acf-4805-bf2c-a56647cc7728 44 | X-CausationId: e07da067-7559-4876-a98a-d95bbeef897c 45 | 46 | { 47 | "slotId": "eee9804d-651a-4f58-abd8-fced2fdb8637", 48 | "reason": "No longer needed" 49 | } 50 | 51 | ### Send day started event 52 | POST https://localhost:5001/api/calendar/2022-08-02/day-started 53 | Content-Type: application/json 54 | X-CorrelationId: 8ea3fb2b-5acf-4805-bf2c-a56647cc7728 55 | X-CausationId: e07da067-7559-4876-a98a-d95bbeef897c 56 | -------------------------------------------------------------------------------- /application/available_slots_handler_test.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 9 | "github.com/EventStore/training-introduction-go/domain/readmodel" 10 | "github.com/EventStore/training-introduction-go/infrastructure" 11 | "github.com/EventStore/training-introduction-go/infrastructure/mongodb" 12 | "github.com/google/uuid" 13 | "github.com/stretchr/testify/assert" 14 | "go.mongodb.org/mongo-driver/mongo" 15 | "go.mongodb.org/mongo-driver/mongo/options" 16 | ) 17 | 18 | func TestAvailableSlotsHandler(t *testing.T) { 19 | client, err := mongo.Connect(context.TODO(), options.Client().ApplyURI("mongodb://localhost")) 20 | defer client.Disconnect(context.TODO()) 21 | assert.NoError(t, err) 22 | 23 | p := &AvailableSlotsTests{ 24 | HandlerTests: infrastructure.NewHandlerTests(t), 25 | 26 | dayId: "dayId", 27 | patientId: "patientId", 28 | reason: "Some cancellation reason", 29 | slotId: uuid.New(), 30 | now: time.Now(), 31 | tenMinutes: 10 * time.Minute, 32 | } 33 | 34 | p.SetHandlerFactory(func() infrastructure.EventHandler { 35 | p.repository = mongodb.NewAvailableSlotsRepository(client.Database(uuid.NewString())) 36 | return NewAvailableSlotsProjection(p.repository) 37 | }) 38 | 39 | t.Run("ShouldAddSlotToTheList", p.ShouldAddSlotToTheList) 40 | t.Run("ShouldHideTheSlotFromListIfBooked", p.ShouldHideTheSlotFromListIfBooked) 41 | t.Run("ShouldShowSlotIfBookingWasCancelled", p.ShouldShowSlotIfBookingWasCancelled) 42 | t.Run("ShouldDeleteSlotIfSlotWasCancelled", p.ShouldDeleteSlotIfSlotWasCancelled) 43 | } 44 | 45 | func (p *AvailableSlotsTests) ShouldAddSlotToTheList(t *testing.T) { 46 | p.Given(events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes)) 47 | p.Then( 48 | []readmodel.AvailableSlot{ 49 | readmodel.NewAvailableSlot( 50 | p.slotId.String(), 51 | p.dayId, 52 | p.now.Format("02-01-2006"), 53 | p.now.Format("15:04:05"), 54 | p.tenMinutes)}, 55 | p.getSlotsAvailableOn(p.now)) 56 | } 57 | 58 | func (p *AvailableSlotsTests) ShouldHideTheSlotFromListIfBooked(t *testing.T) { 59 | p.Given( 60 | events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes), 61 | events.NewSlotBooked(p.slotId, p.dayId, p.patientId)) 62 | p.Then( 63 | []readmodel.AvailableSlot{}, 64 | p.getSlotsAvailableOn(p.now)) 65 | } 66 | 67 | func (p *AvailableSlotsTests) ShouldShowSlotIfBookingWasCancelled(t *testing.T) { 68 | p.Given( 69 | events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes), 70 | events.NewSlotBooked(p.slotId, p.dayId, p.patientId), 71 | events.NewSlotBookingCancelled(p.slotId, p.dayId, p.reason)) 72 | p.Then( 73 | []readmodel.AvailableSlot{ 74 | readmodel.NewAvailableSlot( 75 | p.slotId.String(), 76 | p.dayId, 77 | p.now.Format("02-01-2006"), 78 | p.now.Format("15:04:05"), 79 | p.tenMinutes)}, 80 | p.getSlotsAvailableOn(p.now)) 81 | } 82 | 83 | func (p *AvailableSlotsTests) ShouldDeleteSlotIfSlotWasCancelled(t *testing.T) { 84 | p.Given( 85 | events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes), 86 | events.NewSlotScheduleCancelled(p.slotId, p.dayId)) 87 | p.Then( 88 | []readmodel.AvailableSlot{}, 89 | p.getSlotsAvailableOn(p.now)) 90 | } 91 | 92 | func (p *AvailableSlotsTests) getSlotsAvailableOn(now time.Time) []readmodel.AvailableSlot { 93 | result, err := p.repository.GetSlotsAvailableOn(p.now) 94 | assert.NoError(p.T, err) 95 | 96 | return result 97 | } 98 | 99 | type AvailableSlotsTests struct { 100 | infrastructure.HandlerTests 101 | 102 | repository *mongodb.AvailableSlotsRepository 103 | 104 | dayId string 105 | patientId string 106 | reason string 107 | slotId uuid.UUID 108 | now time.Time 109 | tenMinutes time.Duration 110 | } 111 | -------------------------------------------------------------------------------- /application/available_slots_handler_v2_test.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | "time" 8 | 9 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 10 | "github.com/EventStore/training-introduction-go/domain/readmodel" 11 | "github.com/EventStore/training-introduction-go/infrastructure" 12 | "github.com/EventStore/training-introduction-go/infrastructure/mongodb" 13 | "github.com/google/uuid" 14 | "github.com/stretchr/testify/assert" 15 | "go.mongodb.org/mongo-driver/mongo" 16 | "go.mongodb.org/mongo-driver/mongo/options" 17 | ) 18 | 19 | func TestAvailableSlotsHandlerV2(t *testing.T) { 20 | s := events.NewSlotScheduled(uuid.New(), "dayId", time.Now(), 10*time.Minute) 21 | m := map[string]interface{}{} 22 | 23 | b, e := json.Marshal(s) 24 | 25 | e = json.Unmarshal(b, &m) 26 | assert.NoError(t, e) 27 | 28 | client, err := mongo.Connect(context.TODO(), options.Client().ApplyURI("mongodb://localhost")) 29 | defer client.Disconnect(context.TODO()) 30 | assert.NoError(t, err) 31 | 32 | p := &AvailableSlotsV2Tests{ 33 | HandlerTests: infrastructure.NewHandlerTests(t), 34 | 35 | dayId: "dayId", 36 | patientId: "patientId", 37 | reason: "Some cancellation reason", 38 | slotId: uuid.New(), 39 | now: time.Now(), 40 | tenMinutes: 10 * time.Minute, 41 | } 42 | 43 | // Repeats every event 2x, e.g.: 1 1 2 2 3 3 44 | p.EnableAtLeastOnceMonkey = false 45 | // Repeats all elements except last e.g.: 1 2 3 1 2 46 | p.EnableAtLeastOnceGorilla = false 47 | 48 | p.SetHandlerFactory(func() infrastructure.EventHandler { 49 | p.repository = mongodb.NewAvailableSlotsRepositoryV2(client.Database(uuid.NewString())) 50 | return NewAvailableSlotsProjectionV2(p.repository) 51 | }) 52 | 53 | t.Run("ShouldAddSlotToTheList", p.ShouldAddSlotToTheList) 54 | t.Run("ShouldHideTheSlotFromListIfBooked", p.ShouldHideTheSlotFromListIfBooked) 55 | t.Run("ShouldShowSlotIfBookingWasCancelled", p.ShouldShowSlotIfBookingWasCancelled) 56 | t.Run("ShouldDeleteSlotIfSlotWasCancelled", p.ShouldDeleteSlotIfSlotWasCancelled) 57 | } 58 | 59 | func (p *AvailableSlotsV2Tests) ShouldAddSlotToTheList(t *testing.T) { 60 | p.Given(events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes)) 61 | p.Then( 62 | []readmodel.AvailableSlot{ 63 | readmodel.NewAvailableSlot( 64 | p.slotId.String(), 65 | p.dayId, 66 | p.now.Format("02-01-2006"), 67 | p.now.Format("15:04:05"), 68 | p.tenMinutes)}, 69 | p.getSlotsAvailableOn(p.now)) 70 | } 71 | 72 | func (p *AvailableSlotsV2Tests) ShouldHideTheSlotFromListIfBooked(t *testing.T) { 73 | p.Given( 74 | events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes), 75 | events.NewSlotBooked(p.slotId, p.dayId, p.patientId)) 76 | p.Then( 77 | []readmodel.AvailableSlot{}, 78 | p.getSlotsAvailableOn(p.now)) 79 | } 80 | 81 | func (p *AvailableSlotsV2Tests) ShouldShowSlotIfBookingWasCancelled(t *testing.T) { 82 | p.Given( 83 | events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes), 84 | events.NewSlotBooked(p.slotId, p.dayId, p.patientId), 85 | events.NewSlotBookingCancelled(p.slotId, p.dayId, p.reason)) 86 | p.Then( 87 | []readmodel.AvailableSlot{ 88 | readmodel.NewAvailableSlot( 89 | p.slotId.String(), 90 | p.dayId, 91 | p.now.Format("02-01-2006"), 92 | p.now.Format("15:04:05"), 93 | p.tenMinutes)}, 94 | p.getSlotsAvailableOn(p.now)) 95 | } 96 | 97 | func (p *AvailableSlotsV2Tests) ShouldDeleteSlotIfSlotWasCancelled(t *testing.T) { 98 | p.Given( 99 | events.NewSlotScheduled(p.slotId, p.dayId, p.now, p.tenMinutes), 100 | events.NewSlotScheduleCancelled(p.slotId, p.dayId)) 101 | p.Then( 102 | []readmodel.AvailableSlot{}, 103 | p.getSlotsAvailableOn(p.now)) 104 | } 105 | 106 | func (p *AvailableSlotsV2Tests) getSlotsAvailableOn(now time.Time) []readmodel.AvailableSlot { 107 | result, err := p.repository.GetSlotsAvailableOn(p.now) 108 | assert.NoError(p.T, err) 109 | 110 | return result 111 | } 112 | 113 | type AvailableSlotsV2Tests struct { 114 | infrastructure.HandlerTests 115 | 116 | repository *mongodb.AvailableSlotsRepositoryV2 117 | 118 | dayId string 119 | patientId string 120 | reason string 121 | slotId uuid.UUID 122 | now time.Time 123 | tenMinutes time.Duration 124 | } 125 | -------------------------------------------------------------------------------- /application/available_slots_projection.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 5 | "github.com/EventStore/training-introduction-go/infrastructure" 6 | "github.com/EventStore/training-introduction-go/infrastructure/mongodb" 7 | ) 8 | 9 | type AvailableSlotsProjection struct { 10 | infrastructure.EventHandlerBase 11 | } 12 | 13 | func NewAvailableSlotsProjection(r *mongodb.AvailableSlotsRepository) *AvailableSlotsProjection { 14 | h := infrastructure.NewEventHandler() 15 | h.When(events.SlotScheduled{}, func(e interface{}, _ infrastructure.EventMetadata) error { 16 | s := e.(events.SlotScheduled) 17 | return r.AddSlot(mongodb.AvailableSlot{ 18 | Id: s.SlotId.String(), 19 | DayId: s.DayId, 20 | Date: s.StartTime.Format("02-01-2006"), 21 | StartTime: s.StartTime.Format("15:04:05"), 22 | Duration: s.Duration}) 23 | }) 24 | 25 | h.When(events.SlotBooked{}, func(e interface{}, _ infrastructure.EventMetadata) error { 26 | b := e.(events.SlotBooked) 27 | return r.HideSlot(b.SlotId) 28 | }) 29 | 30 | h.When(events.SlotBookingCancelled{}, func(e interface{}, _ infrastructure.EventMetadata) error { 31 | c := e.(events.SlotBookingCancelled) 32 | return r.ShowSlot(c.SlotId) 33 | }) 34 | 35 | h.When(events.SlotScheduleCancelled{}, func(e interface{}, _ infrastructure.EventMetadata) error { 36 | c := e.(events.SlotScheduleCancelled) 37 | return r.DeleteSlot(c.SlotId) 38 | }) 39 | 40 | return &AvailableSlotsProjection{h} 41 | } 42 | -------------------------------------------------------------------------------- /application/available_slots_projection_v2.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 5 | "github.com/EventStore/training-introduction-go/infrastructure" 6 | "github.com/EventStore/training-introduction-go/infrastructure/mongodb" 7 | ) 8 | 9 | type AvailableSlotsProjectionV2 struct { 10 | infrastructure.EventHandlerBase 11 | } 12 | 13 | func NewAvailableSlotsProjectionV2(r *mongodb.AvailableSlotsRepositoryV2) *AvailableSlotsProjectionV2 { 14 | p := infrastructure.NewEventHandler() 15 | p.When(events.SlotScheduled{}, func(e interface{}, m infrastructure.EventMetadata) error { 16 | s := e.(events.SlotScheduled) 17 | return r.AddSlot(mongodb.AvailableSlotV2{ 18 | Id: s.SlotId.String(), 19 | DayId: s.DayId, 20 | Date: s.StartTime.Format("02-01-2006"), 21 | StartTime: s.StartTime.Format("15:04:05"), 22 | Duration: s.Duration}) 23 | }) 24 | 25 | p.When(events.SlotBooked{}, func(e interface{}, m infrastructure.EventMetadata) error { 26 | b := e.(events.SlotBooked) 27 | return r.HideSlot(b.SlotId) 28 | }) 29 | 30 | p.When(events.SlotBookingCancelled{}, func(e interface{}, m infrastructure.EventMetadata) error { 31 | c := e.(events.SlotBookingCancelled) 32 | return r.ShowSlot(c.SlotId) 33 | }) 34 | 35 | p.When(events.SlotScheduleCancelled{}, func(e interface{}, m infrastructure.EventMetadata) error { 36 | c := e.(events.SlotScheduleCancelled) 37 | return r.DeleteSlot(c.SlotId) 38 | }) 39 | 40 | return &AvailableSlotsProjectionV2{p} 41 | } 42 | -------------------------------------------------------------------------------- /application/day_archiver_process_manager.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/EventStore/training-introduction-go/domain/doctorday" 7 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 8 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 9 | "github.com/EventStore/training-introduction-go/domain/readmodel" 10 | "github.com/EventStore/training-introduction-go/eventsourcing" 11 | "github.com/EventStore/training-introduction-go/infrastructure" 12 | ) 13 | 14 | type DayArchiverProcessManager struct { 15 | infrastructure.EventHandlerBase 16 | } 17 | 18 | func NewDayArchiverProcessManager(s eventsourcing.ColdStorage, a readmodel.ArchivableDaysRepository, 19 | c infrastructure.CommandStore, es infrastructure.EventStore, archiveThreshold time.Duration) *DayArchiverProcessManager { 20 | h := infrastructure.NewEventHandler() 21 | 22 | h.When(events.DayScheduled{}, func(e interface{}, _ infrastructure.EventMetadata) error { 23 | d := e.(events.DayScheduled) 24 | a.Add(readmodel.NewArchivableDay(d.DayId, d.Date)) 25 | return nil 26 | }) 27 | 28 | h.When(events.CalendarDayStarted{}, func(e interface{}, m infrastructure.EventMetadata) error { 29 | d := e.(events.CalendarDayStarted) 30 | archivableDays, err := a.FindAll(d.Date.Add(archiveThreshold)) 31 | if err != nil { 32 | return err 33 | } 34 | for _, a := range archivableDays { 35 | err := c.Send(commands.NewArchiveDaySchedule(a.Id), infrastructure.NewCommandMetadataFrom(m)) 36 | if err != nil { 37 | return err 38 | } 39 | } 40 | return nil 41 | }) 42 | 43 | h.When(events.DayScheduleArchived{}, func(e interface{}, _ infrastructure.EventMetadata) error { 44 | d := e.(events.DayScheduleArchived) 45 | 46 | streamName := eventsourcing.GetStreamNameWithId(&doctorday.Day{}, d.DayId) 47 | events, err := es.LoadEventsFromStart(streamName) 48 | if err != nil { 49 | return err 50 | } 51 | s.SaveAll(events) 52 | lastVersion, err := es.GetLastVersion(streamName) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | return es.TruncateStream(streamName, lastVersion) 58 | }) 59 | 60 | return &DayArchiverProcessManager{h} 61 | } 62 | -------------------------------------------------------------------------------- /application/day_archiver_process_manager_test.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/EventStore/EventStore-Client-Go/esdb" 9 | "github.com/EventStore/training-introduction-go/domain/doctorday" 10 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 11 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 12 | "github.com/EventStore/training-introduction-go/eventsourcing" 13 | "github.com/EventStore/training-introduction-go/infrastructure" 14 | "github.com/EventStore/training-introduction-go/infrastructure/inmemory" 15 | "github.com/google/uuid" 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | func TestDayArchiver(t *testing.T) { 20 | client, err := createESDBClient() 21 | assert.NoError(t, err) 22 | 23 | typeMapper := eventsourcing.NewTypeMapper() 24 | esSerde := infrastructure.NewEsEventSerde(typeMapper) 25 | tenantPrefix := fmt.Sprintf("day_archiver_tests_%s_", uuid.NewString()) 26 | eventStore := infrastructure.NewEsEventStore(client, tenantPrefix, esSerde) 27 | cmdStore := infrastructure.NewEsCommandStore(eventStore, nil, nil, nil) 28 | doctorday.RegisterTypes(typeMapper) 29 | 30 | p := &DayArchiverTests{ 31 | HandlerTests: infrastructure.NewHandlerTests(t), 32 | 33 | dayId: "dayId", 34 | patientId: "patientId", 35 | reason: "Some cancellation reason", 36 | slotId: uuid.New(), 37 | now: time.Now().Truncate(time.Second), 38 | tenMinutes: 10 * time.Minute, 39 | eventStore: eventStore, 40 | coldStorage: inmemory.NewColdStorage(), 41 | } 42 | 43 | p.SetHandlerFactory(func() infrastructure.EventHandler { 44 | r := inmemory.NewArchivableDaysRepository() 45 | return NewDayArchiverProcessManager(p.coldStorage, r, cmdStore, eventStore, time.Hour*-24) 46 | }) 47 | 48 | t.Run("ShouldArchiveAllEventsAndTruncateAllExceptLastOne", p.ShouldArchiveAllEventsAndTruncateAllExceptLastOne) 49 | t.Run("ShouldSendArchiveCommandForAllSlotsCompleted180DaysAgo", p.ShouldSendArchiveCommandForAllSlotsCompleted180DaysAgo) 50 | } 51 | 52 | func (a *DayArchiverTests) ShouldArchiveAllEventsAndTruncateAllExceptLastOne(t *testing.T) { 53 | dayId := uuid.NewString() 54 | scheduled := events.NewSlotScheduled(uuid.New(), dayId, a.now, a.tenMinutes) 55 | slotBooked := events.NewSlotBooked(scheduled.SlotId, dayId, "PatientId") 56 | dayArchived := events.NewDayScheduleArchived(dayId) 57 | metadata := infrastructure.NewCommandMetadata(uuid.New(), uuid.New()) 58 | 59 | events := []interface{}{scheduled, slotBooked, dayArchived} 60 | 61 | streamName := eventsourcing.GetStreamNameWithId(&doctorday.Day{}, dayId) 62 | err := a.eventStore.AppendEventsToAny(streamName, metadata, events...) 63 | assert.NoError(t, err) 64 | 65 | a.Given(dayArchived) 66 | a.Then(events, a.coldStorage.Events) 67 | 68 | loadedEvents, err := a.eventStore.LoadEventsFromStart(streamName) 69 | assert.NoError(t, err) 70 | assert.Len(t, loadedEvents, 1) 71 | 72 | a.Then(dayArchived, loadedEvents[0]) 73 | } 74 | 75 | func (a *DayArchiverTests) ShouldSendArchiveCommandForAllSlotsCompleted180DaysAgo(t *testing.T) { 76 | dayId := uuid.NewString() 77 | date := time.Now().AddDate(0, 0, -180) 78 | dayScheduled := events.NewDayScheduled(dayId, uuid.New(), date) 79 | calenderDayStarted := events.NewCalendarDayStarted(a.now) 80 | 81 | a.Given(dayScheduled, calenderDayStarted) 82 | 83 | cmds, err := a.eventStore.LoadCommand("async_command_handler-day") 84 | assert.NoError(t, err) 85 | assert.Len(t, cmds, 1) 86 | 87 | a.Then( 88 | commands.NewArchiveDaySchedule(dayId), 89 | cmds[0].Command) 90 | } 91 | 92 | func createESDBClient() (*esdb.Client, error) { 93 | settings, err := esdb.ParseConnectionString("esdb://localhost:2113?tls=false") 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | db, err := esdb.NewClient(settings) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return db, nil 104 | } 105 | 106 | type DayArchiverTests struct { 107 | infrastructure.HandlerTests 108 | 109 | eventStore *infrastructure.EsEventStore 110 | coldStorage *inmemory.ColdStorage 111 | 112 | dayId string 113 | patientId string 114 | reason string 115 | slotId uuid.UUID 116 | now time.Time 117 | tenMinutes time.Duration 118 | } 119 | -------------------------------------------------------------------------------- /application/overbooking_process_manager.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 5 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 6 | "github.com/EventStore/training-introduction-go/domain/readmodel" 7 | "github.com/EventStore/training-introduction-go/infrastructure" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type OverbookingProcessManager struct { 12 | infrastructure.EventHandlerBase 13 | } 14 | 15 | func NewOverbookingProcessManager(r readmodel.BookedSlotsRepository, c infrastructure.CommandStore, bookingLimitPerPatient int) *OverbookingProcessManager { 16 | h := infrastructure.NewEventHandler() 17 | 18 | h.When(events.SlotScheduled{}, func(e interface{}, _ infrastructure.EventMetadata) error { 19 | s := e.(events.SlotScheduled) 20 | r.AddSlot(readmodel.NewBookedSlot(s.SlotId.String(), s.DayId, int(s.StartTime.Month()))) 21 | return nil 22 | }) 23 | 24 | h.When(events.SlotBooked{}, func(e interface{}, m infrastructure.EventMetadata) error { 25 | s := e.(events.SlotBooked) 26 | r.MarkSlotAsBooked(s.SlotId.String(), s.PatientId) 27 | 28 | slot, err := r.GetSlot(s.SlotId.String()) 29 | if err != nil { 30 | return err 31 | } 32 | count, err := r.CountByPatientAndMonth(s.PatientId, slot.Month) 33 | if err != nil { 34 | return err 35 | } 36 | if count > bookingLimitPerPatient { 37 | metadata := infrastructure.NewCommandMetadata(m.CorrelationId.Value, uuid.New()) 38 | err := c.Send(commands.NewCancelSlotBooking(s.SlotId, slot.DayId, "overbooked"), metadata) 39 | if err != nil { 40 | return err 41 | } 42 | } 43 | return nil 44 | }) 45 | 46 | h.When(events.SlotBookingCancelled{}, func(e interface{}, _ infrastructure.EventMetadata) error { 47 | s := e.(events.SlotBookingCancelled) 48 | r.MarkSlotAsAvailable(s.SlotId.String()) 49 | return nil 50 | }) 51 | 52 | return &OverbookingProcessManager{h} 53 | } 54 | -------------------------------------------------------------------------------- /application/overbooking_process_manager_test.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/EventStore/training-introduction-go/domain/doctorday" 10 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 11 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 12 | "github.com/EventStore/training-introduction-go/domain/readmodel" 13 | "github.com/EventStore/training-introduction-go/eventsourcing" 14 | "github.com/EventStore/training-introduction-go/infrastructure" 15 | "github.com/EventStore/training-introduction-go/infrastructure/mongodb" 16 | "github.com/google/uuid" 17 | "github.com/stretchr/testify/assert" 18 | "go.mongodb.org/mongo-driver/mongo" 19 | "go.mongodb.org/mongo-driver/mongo/options" 20 | ) 21 | 22 | func TestOverbooking(t *testing.T) { 23 | client, err := createESDBClient() 24 | assert.NoError(t, err) 25 | 26 | tm := eventsourcing.NewTypeMapper() 27 | doctorday.RegisterTypes(tm) 28 | esSerde := infrastructure.NewEsEventSerde(tm) 29 | tenantPrefix := fmt.Sprintf("overbooking_tests_%s_", uuid.NewString()) 30 | eventStore := infrastructure.NewEsEventStore(client, tenantPrefix, esSerde) 31 | cmdStore := infrastructure.NewEsCommandStore(eventStore, nil, nil, nil) 32 | 33 | mongoClient, err := mongo.Connect(context.TODO(), options.Client().ApplyURI("mongodb://localhost")) 34 | defer mongoClient.Disconnect(context.TODO()) 35 | assert.NoError(t, err) 36 | 37 | p := &OverbookingTests{ 38 | HandlerTests: infrastructure.NewHandlerTests(t), 39 | 40 | bookingLimitPerPatient: 3, 41 | 42 | dayId: "dayId", 43 | patientId: "patient 1", 44 | reason: "Some cancellation reason", 45 | slotId: uuid.New(), 46 | now: time.Now().Truncate(time.Second), 47 | tenMinutes: 10 * time.Minute, 48 | eventStore: eventStore, 49 | } 50 | 51 | p.SetHandlerFactory(func() infrastructure.EventHandler { 52 | p.repository = mongodb.NewBookedSlotsRepository(mongoClient.Database(uuid.NewString())) 53 | return NewOverbookingProcessManager(p.repository, cmdStore, p.bookingLimitPerPatient) 54 | }) 55 | 56 | t.Run("ShouldIncrementTheVisitCounterWhenSlotIsBooked", p.ShouldIncrementTheVisitCounterWhenSlotIsBooked) 57 | t.Run("ShouldDecrementTheVisitCounterWhenSlotBookingIsCancelled", p.ShouldDecrementTheVisitCounterWhenSlotBookingIsCancelled) 58 | t.Run("ShouldIssueCommandToCancelSlotIfBookingLimitWasReached", p.ShouldIssueCommandToCancelSlotIfBookingLimitWasReached) 59 | } 60 | 61 | func (a *OverbookingTests) ShouldIncrementTheVisitCounterWhenSlotIsBooked(t *testing.T) { 62 | dayId := uuid.NewString() 63 | slotSchedule1 := events.NewSlotScheduled(uuid.New(), dayId, a.now, a.tenMinutes) 64 | slotSchedule2 := events.NewSlotScheduled(uuid.New(), dayId, a.now.Add(10*time.Minute), a.tenMinutes) 65 | slotBooked1 := events.NewSlotBooked(slotSchedule1.SlotId, dayId, a.patientId) 66 | slotBooked2 := events.NewSlotBooked(slotSchedule2.SlotId, dayId, a.patientId) 67 | 68 | a.Given(slotSchedule1, slotSchedule2, slotBooked1, slotBooked2) 69 | 70 | count, err := a.repository.CountByPatientAndMonth(a.patientId, int(a.now.Month())) 71 | assert.NoError(t, err) 72 | 73 | a.Then(2, count) 74 | } 75 | 76 | func (a *OverbookingTests) ShouldDecrementTheVisitCounterWhenSlotBookingIsCancelled(t *testing.T) { 77 | dayId := uuid.NewString() 78 | slotSchedule1 := events.NewSlotScheduled(uuid.New(), dayId, a.now, a.tenMinutes) 79 | slotSchedule2 := events.NewSlotScheduled(uuid.New(), dayId, a.now.Add(10*time.Minute), a.tenMinutes) 80 | slotBooked1 := events.NewSlotBooked(slotSchedule1.SlotId, dayId, a.patientId) 81 | slotBooked2 := events.NewSlotBooked(slotSchedule2.SlotId, dayId, a.patientId) 82 | slotBookingCancelled := events.NewSlotBookingCancelled(slotSchedule2.SlotId, dayId, "no longer needed") 83 | 84 | a.Given(slotSchedule1, slotSchedule2, slotBooked1, slotBooked2, slotBookingCancelled) 85 | 86 | count, err := a.repository.CountByPatientAndMonth(a.patientId, int(a.now.Month())) 87 | assert.NoError(t, err) 88 | 89 | a.Then(1, count) 90 | } 91 | 92 | func (a *OverbookingTests) ShouldIssueCommandToCancelSlotIfBookingLimitWasReached(t *testing.T) { 93 | dayId := uuid.NewString() 94 | slotSchedule1 := events.NewSlotScheduled(uuid.New(), dayId, a.now, a.tenMinutes) 95 | slotSchedule2 := events.NewSlotScheduled(uuid.New(), dayId, a.now.Add(10*time.Minute), a.tenMinutes) 96 | slotSchedule3 := events.NewSlotScheduled(uuid.New(), dayId, a.now.Add(20*time.Minute), a.tenMinutes) 97 | slotSchedule4 := events.NewSlotScheduled(uuid.New(), dayId, a.now.Add(30*time.Minute), a.tenMinutes) 98 | slotBooked1 := events.NewSlotBooked(slotSchedule1.SlotId, dayId, a.patientId) 99 | slotBooked2 := events.NewSlotBooked(slotSchedule2.SlotId, dayId, a.patientId) 100 | slotBooked3 := events.NewSlotBooked(slotSchedule3.SlotId, dayId, a.patientId) 101 | slotBooked4 := events.NewSlotBooked(slotSchedule4.SlotId, dayId, a.patientId) 102 | 103 | a.Given( 104 | slotSchedule1, slotSchedule2, slotSchedule3, slotSchedule4, 105 | slotBooked1, slotBooked2, slotBooked3, slotBooked4) 106 | 107 | cmd, err := a.eventStore.LoadCommand("async_command_handler-day") 108 | assert.NoError(t, err) 109 | 110 | a.Then( 111 | commands.NewCancelSlotBooking(slotSchedule4.SlotId, dayId, "overbooked"), 112 | cmd[0].Command) 113 | } 114 | 115 | type OverbookingTests struct { 116 | infrastructure.HandlerTests 117 | 118 | eventStore *infrastructure.EsEventStore 119 | repository readmodel.BookedSlotsRepository 120 | 121 | bookingLimitPerPatient int 122 | 123 | dayId string 124 | patientId string 125 | reason string 126 | slotId uuid.UUID 127 | now time.Time 128 | tenMinutes time.Duration 129 | } 130 | -------------------------------------------------------------------------------- /controllers/api_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "path" 7 | "time" 8 | 9 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 10 | "github.com/EventStore/training-introduction-go/domain/readmodel" 11 | "github.com/EventStore/training-introduction-go/infrastructure" 12 | "github.com/google/uuid" 13 | "github.com/labstack/echo/v4" 14 | ) 15 | 16 | const ( 17 | DateLayout = "2006-01-02" 18 | ) 19 | 20 | type SlotsController struct { 21 | availableSlotsRepository readmodel.AvailableSlotsRepository 22 | dispatcher *infrastructure.Dispatcher 23 | eventStore infrastructure.EventStore 24 | } 25 | 26 | func NewSlotsController(d *infrastructure.Dispatcher, a readmodel.AvailableSlotsRepository, e infrastructure.EventStore) *SlotsController { 27 | return &SlotsController{ 28 | dispatcher: d, 29 | availableSlotsRepository: a, 30 | eventStore: e, 31 | } 32 | } 33 | 34 | func (c *SlotsController) Register(prefix string, e *echo.Echo) { 35 | e.GET(path.Join(prefix, "/slots/today/available"), c.AvailableTodayHandler) 36 | e.GET(path.Join(prefix, "/slots/:date/available"), c.AvailableHandler) 37 | 38 | e.POST(path.Join(prefix, "/doctor/schedule"), c.ScheduleDayHandler) 39 | e.POST(path.Join(prefix, "/slots/:dayId/cancel-booking"), c.CancelBookingHandler) 40 | e.POST(path.Join(prefix, "/slots/:dayId/book"), c.BookSlotHandler) 41 | e.POST(path.Join(prefix, "/calendar/:date/day-started"), c.CalendarDayStartedHandler) 42 | 43 | } 44 | 45 | func (c *SlotsController) AvailableTodayHandler(ctx echo.Context) error { 46 | today, err := time.Parse(DateLayout, "2020-08-01") 47 | if err != nil { 48 | return ctx.String(http.StatusBadRequest, err.Error()) 49 | } 50 | 51 | return c.availableHandler(ctx, today) 52 | } 53 | 54 | func (c *SlotsController) AvailableHandler(ctx echo.Context) error { 55 | date, err := time.Parse(DateLayout, ctx.Param("date")) 56 | if err != nil { 57 | return ctx.String(http.StatusBadRequest, err.Error()) 58 | } 59 | 60 | return c.availableHandler(ctx, date) 61 | } 62 | 63 | func (c *SlotsController) availableHandler(ctx echo.Context, date time.Time) error { 64 | availableSlots, err := c.availableSlotsRepository.GetSlotsAvailableOn(date) 65 | if err != nil { 66 | return ctx.String(http.StatusBadRequest, err.Error()) 67 | } 68 | 69 | response := make([]AvailableSlotResponse, 0) 70 | for _, a := range availableSlots { 71 | response = append(response, AvailableSlotResponseFrom(a)) 72 | } 73 | 74 | return ctx.JSON(http.StatusOK, response) 75 | } 76 | 77 | func (c *SlotsController) ScheduleDayHandler(ctx echo.Context) error { 78 | req := ScheduleDayRequest{} 79 | if err := ctx.Bind(&req); err != nil { 80 | return ctx.String(http.StatusBadRequest, err.Error()) 81 | } 82 | 83 | scheduleDay, err := req.ToCommand() 84 | if err != nil { 85 | return ctx.String(http.StatusBadRequest, err.Error()) 86 | } 87 | 88 | metadata, err := c.GetCommandMetadata(ctx) 89 | if err != nil { 90 | return ctx.String(http.StatusBadRequest, err.Error()) 91 | } 92 | 93 | err = c.dispatcher.Dispatch(scheduleDay, metadata) 94 | if err != nil { 95 | return ctx.String(http.StatusBadRequest, err.Error()) 96 | } 97 | 98 | url := fmt.Sprintf("/api/slots/%s/available", scheduleDay.Date.Format(DateLayout)) 99 | return ctx.Redirect(http.StatusFound, url) 100 | } 101 | 102 | func (c *SlotsController) CancelBookingHandler(ctx echo.Context) error { 103 | req := CancelSlotBookingRequest{} 104 | if err := ctx.Bind(&req); err != nil { 105 | return ctx.String(http.StatusBadRequest, err.Error()) 106 | } 107 | 108 | metadata, err := c.GetCommandMetadata(ctx) 109 | if err != nil { 110 | return ctx.String(http.StatusBadRequest, err.Error()) 111 | } 112 | 113 | err = c.dispatcher.Dispatch(req.ToCommand(ctx.Param("dayId")), metadata) 114 | if err != nil { 115 | return ctx.String(http.StatusBadRequest, err.Error()) 116 | } 117 | 118 | return ctx.String(http.StatusOK, "successfully cancelled booking") 119 | } 120 | 121 | func (c *SlotsController) BookSlotHandler(ctx echo.Context) error { 122 | req := BookSlotRequest{} 123 | if err := ctx.Bind(&req); err != nil { 124 | return ctx.String(http.StatusBadRequest, err.Error()) 125 | } 126 | 127 | metadata, err := c.GetCommandMetadata(ctx) 128 | if err != nil { 129 | return ctx.String(http.StatusBadRequest, err.Error()) 130 | } 131 | 132 | err = c.dispatcher.Dispatch(req.ToCommand(ctx.Param("dayId")), metadata) 133 | if err != nil { 134 | return ctx.String(http.StatusBadRequest, err.Error()) 135 | } 136 | 137 | return ctx.String(http.StatusOK, "slot successfully booked") 138 | } 139 | 140 | func (c *SlotsController) CalendarDayStartedHandler(ctx echo.Context) error { 141 | date, err := time.Parse(DateLayout, ctx.Param("date")) 142 | if err != nil { 143 | return ctx.String(http.StatusBadRequest, err.Error()) 144 | } 145 | 146 | metadata, err := c.GetCommandMetadata(ctx) 147 | if err != nil { 148 | return ctx.String(http.StatusBadRequest, err.Error()) 149 | } 150 | 151 | err = c.eventStore.AppendEventsToAny("doctorday-time-events", metadata, events.NewCalendarDayStarted(date)) 152 | if err != nil { 153 | return ctx.String(http.StatusBadRequest, err.Error()) 154 | } 155 | 156 | return ctx.String(http.StatusOK, "calendar day successfully started") 157 | } 158 | 159 | func (c *SlotsController) GetCommandMetadata(ctx echo.Context) (infrastructure.CommandMetadata, error) { 160 | correlationId, err := getHeaderUUIDValue(ctx, "X-CorrelationId") 161 | if err != nil { 162 | return infrastructure.CommandMetadata{}, nil 163 | } 164 | 165 | causationId, err := getHeaderUUIDValue(ctx, "X-CausationId") 166 | if err != nil { 167 | return infrastructure.CommandMetadata{}, nil 168 | } 169 | 170 | return infrastructure.NewCommandMetadata(correlationId, causationId), nil 171 | } 172 | 173 | func getHeaderUUIDValue(ctx echo.Context, name string) (uuid.UUID, error) { 174 | v := ctx.Request().Header.Get("X-CorrelationId") 175 | if v == "" { 176 | return uuid.UUID{}, fmt.Errorf("please provide an %s header", name) 177 | } 178 | 179 | id, err := uuid.Parse(v) 180 | if err != nil { 181 | return uuid.UUID{}, fmt.Errorf("please provide a valid %s header", name) 182 | } 183 | 184 | return id, nil 185 | } 186 | -------------------------------------------------------------------------------- /controllers/available_slot_response.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/EventStore/training-introduction-go/domain/readmodel" 7 | ) 8 | 9 | type AvailableSlotResponse struct { 10 | DayId string 11 | SlotId string 12 | Date string 13 | Time string 14 | Duration string 15 | } 16 | 17 | func AvailableSlotResponseFrom(a readmodel.AvailableSlot) AvailableSlotResponse { 18 | return AvailableSlotResponse{ 19 | DayId: a.DayId, 20 | SlotId: a.Id, 21 | Date: a.Date, 22 | Time: a.StartTime, 23 | Duration: time.Time{}.Add(a.Duration).Format("15:04:05"), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /controllers/book_slot_request.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type BookSlotRequest struct { 9 | SlotId uuid.UUID `json:"slotId"` 10 | PatientId string `json:"patientId"` 11 | } 12 | 13 | func (r *BookSlotRequest) ToCommand(dayId string) commands.BookSlot { 14 | return commands.NewBookSlot(r.SlotId, dayId, r.PatientId) 15 | } 16 | -------------------------------------------------------------------------------- /controllers/cancel_slot_booking_request.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type CancelSlotBookingRequest struct { 9 | SlotId uuid.UUID `json:"slotId"` 10 | Reason string `json:"reason"` 11 | } 12 | 13 | func (r *CancelSlotBookingRequest) ToCommand(dayId string) commands.CancelSlotBooking { 14 | return commands.NewCancelSlotBooking(r.SlotId, dayId, r.Reason) 15 | } 16 | -------------------------------------------------------------------------------- /controllers/schedule_day_request.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | const ( 13 | ScheduleDayDateFormat = "2006-01-02" 14 | ScheduleDayTimeFormat = "15:04:05" 15 | ) 16 | 17 | type ScheduleDayRequest struct { 18 | DoctorId uuid.UUID `json:"doctorId"` 19 | Date ScheduleDayDate `json:"date"` 20 | Slots []SlotRequest `json:"slots"` 21 | } 22 | 23 | type SlotRequest struct { 24 | Duration SlotDuration `json:"duration"` 25 | StartTime string `json:"startTime"` 26 | } 27 | 28 | func (r *ScheduleDayRequest) ToCommand() (commands.ScheduleDay, error) { 29 | slots := make([]commands.ScheduledSlot, 0) 30 | date := r.Date.Truncate(24 * time.Hour) 31 | 32 | for _, slot := range r.Slots { 33 | duration := slot.Duration 34 | startTime, err := time.Parse(ScheduleDayTimeFormat, slot.StartTime) 35 | slotStartTime := date.Add( 36 | time.Duration(startTime.Hour())*time.Hour + 37 | time.Duration(startTime.Minute())*time.Minute + 38 | time.Duration(startTime.Second())*time.Second) 39 | 40 | if err != nil { 41 | return commands.ScheduleDay{}, err 42 | } 43 | 44 | slots = append(slots, commands.NewScheduledSlot(slotStartTime, duration.ToDuration())) 45 | } 46 | 47 | return commands.NewScheduleDay(r.DoctorId, r.Date.Time, slots), nil 48 | } 49 | 50 | // 51 | // Define custom type for date format 52 | // 53 | 54 | type ScheduleDayDate struct { 55 | time.Time 56 | } 57 | 58 | func (d *ScheduleDayDate) UnmarshalJSON(b []byte) error { 59 | var s string 60 | if err := json.Unmarshal(b, &s); err != nil { 61 | return err 62 | } 63 | t, err := time.Parse(ScheduleDayDateFormat, s) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | d.Time = t 69 | return nil 70 | } 71 | 72 | func (d ScheduleDayDate) MarshalJSON() ([]byte, error) { 73 | return json.Marshal(d.Time.Format(ScheduleDayDateFormat)) 74 | } 75 | 76 | // 77 | // Define custom type for time format 78 | // 79 | 80 | type SlotDuration struct { 81 | time.Time 82 | } 83 | 84 | func (d *SlotDuration) UnmarshalJSON(b []byte) error { 85 | var s string 86 | if err := json.Unmarshal(b, &s); err != nil { 87 | return err 88 | } 89 | t, err := time.Parse(ScheduleDayTimeFormat, s) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | d.Time = t 95 | return nil 96 | } 97 | 98 | func (d SlotDuration) MarshalJSON() ([]byte, error) { 99 | return json.Marshal( 100 | fmt.Sprintf("%02d:%02d:%02d", d.Hour(), d.Minute(), d.Second())) 101 | } 102 | 103 | func (d SlotDuration) ToDuration() time.Duration { 104 | return time.Duration(d.Hour())*time.Hour + 105 | time.Duration(d.Minute())*time.Minute + 106 | time.Duration(d.Second())*time.Second 107 | } 108 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | eventstore.db: 5 | image: eventstore/eventstore:21.10.0-buster-slim 6 | # image: ghcr.io/eventstore/eventstore:21.10.0-alpha-arm64v8 7 | environment: 8 | - EVENTSTORE_INSECURE=true 9 | - EVENTSTORE_RUN_PROJECTIONS=all 10 | - EVENTSTORE_START_STANDARD_PROJECTIONS=true 11 | - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true 12 | ports: 13 | - "2113:2113" 14 | mong.db: 15 | image: mongo:4.4.13-focal 16 | environment: 17 | - MONGO_INITDB_DATABASE=projections 18 | ports: 19 | - "27017-27019:27017-27019" -------------------------------------------------------------------------------- /domain/doctorday/command_handlers.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 5 | "github.com/EventStore/training-introduction-go/infrastructure" 6 | ) 7 | 8 | type CommandHandlers struct { 9 | *infrastructure.CommandHandlerBase 10 | } 11 | 12 | func NewHandlers(repository DayRepository) CommandHandlers { 13 | commandHandler := CommandHandlers{infrastructure.NewCommandHandler()} 14 | 15 | commandHandler.Register(commands.ScheduleDay{}, func(c infrastructure.Command, m infrastructure.CommandMetadata) error { 16 | cmd := c.(commands.ScheduleDay) 17 | id := NewDayID(NewDoctorID(cmd.DoctorId), cmd.Date) 18 | day, err := repository.Get(id) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | err = day.ScheduleDay(NewDoctorID(cmd.DoctorId), cmd.Date, cmd.Slots) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | repository.Save(day, m) 29 | return nil 30 | }) 31 | 32 | commandHandler.Register(commands.BookSlot{}, func(c infrastructure.Command, m infrastructure.CommandMetadata) error { 33 | cmd := c.(commands.BookSlot) 34 | day, err := repository.Get(NewDayIDFrom(cmd.DayId)) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | err = day.BookSlot(NewSlotID(cmd.SlotId), NewPatientID(cmd.PatientId)) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | repository.Save(day, m) 45 | return nil 46 | }) 47 | 48 | commandHandler.Register(commands.CancelSlotBooking{}, func(c infrastructure.Command, m infrastructure.CommandMetadata) error { 49 | cmd := c.(commands.CancelSlotBooking) 50 | day, err := repository.Get(NewDayIDFrom(cmd.DayId)) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | err = day.CancelSlotBooking(NewSlotID(cmd.SlotId), cmd.Reason) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | repository.Save(day, m) 61 | return nil 62 | }) 63 | 64 | commandHandler.Register(commands.ScheduleSlot{}, func(c infrastructure.Command, m infrastructure.CommandMetadata) error { 65 | cmd := c.(commands.ScheduleSlot) 66 | day, err := repository.Get(NewDayID(NewDoctorID(cmd.DoctorId), cmd.Start)) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | err = day.ScheduleSlot(NewSlotID(cmd.SlotId).Value, cmd.Start, cmd.Duration) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | repository.Save(day, m) 77 | return nil 78 | }) 79 | 80 | commandHandler.Register(commands.CancelDaySchedule{}, func(c infrastructure.Command, m infrastructure.CommandMetadata) error { 81 | cmd := c.(commands.CancelDaySchedule) 82 | day, err := repository.Get(NewDayIDFrom(cmd.DayId)) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | err = day.Cancel() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | repository.Save(day, m) 93 | return nil 94 | }) 95 | 96 | commandHandler.Register(commands.ArchiveDaySchedule{}, func(c infrastructure.Command, m infrastructure.CommandMetadata) error { 97 | cmd := c.(commands.ArchiveDaySchedule) 98 | day, err := repository.Get(NewDayIDFrom(cmd.DayId)) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | err = day.Archive() 104 | if err != nil { 105 | return err 106 | } 107 | 108 | repository.Save(day, m) 109 | return nil 110 | }) 111 | 112 | return commandHandler 113 | } 114 | -------------------------------------------------------------------------------- /domain/doctorday/commands/archive_day_schedule.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | type ArchiveDaySchedule struct { 4 | DayId string 5 | } 6 | 7 | func NewArchiveDaySchedule(dayId string) ArchiveDaySchedule { 8 | return ArchiveDaySchedule{ 9 | DayId: dayId, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /domain/doctorday/commands/book_slot.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import "github.com/google/uuid" 4 | 5 | type BookSlot struct { 6 | DayId string 7 | SlotId uuid.UUID 8 | PatientId string 9 | } 10 | 11 | func NewBookSlot(slotId uuid.UUID, dayId, patientId string) BookSlot { 12 | return BookSlot{ 13 | DayId: dayId, 14 | SlotId: slotId, 15 | PatientId: patientId, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /domain/doctorday/commands/cancel_day_schedule.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | type CancelDaySchedule struct { 4 | DayId string 5 | } 6 | 7 | func NewCancelDaySchedule(dayId string) CancelDaySchedule { 8 | return CancelDaySchedule{ 9 | DayId: dayId, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /domain/doctorday/commands/cancel_slot_booking.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | type CancelSlotBooking struct { 8 | DayId string 9 | SlotId uuid.UUID 10 | Reason string 11 | } 12 | 13 | func NewCancelSlotBooking(slotId uuid.UUID, dayId, reason string) CancelSlotBooking { 14 | return CancelSlotBooking{ 15 | DayId: dayId, 16 | SlotId: slotId, 17 | Reason: reason, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /domain/doctorday/commands/schedule_day.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type ScheduleDay struct { 10 | DoctorId uuid.UUID 11 | Date time.Time 12 | Slots []ScheduledSlot 13 | } 14 | 15 | type ScheduledSlot struct { 16 | StartTime time.Time 17 | Duration time.Duration 18 | } 19 | 20 | func NewScheduleDay(doctorId uuid.UUID, date time.Time, slots []ScheduledSlot) ScheduleDay { 21 | return ScheduleDay{ 22 | DoctorId: doctorId, 23 | Date: date, 24 | Slots: slots, 25 | } 26 | } 27 | 28 | func NewScheduledSlot(start time.Time, duration time.Duration) ScheduledSlot { 29 | return ScheduledSlot{ 30 | StartTime: start, 31 | Duration: duration, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /domain/doctorday/commands/schedule_slot.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type ScheduleSlot struct { 10 | SlotId uuid.UUID 11 | DoctorId uuid.UUID 12 | Start time.Time 13 | Duration time.Duration 14 | } 15 | 16 | func NewScheduleSlot(slotId, doctorId uuid.UUID, start time.Time, duration time.Duration) ScheduleSlot { 17 | return ScheduleSlot{ 18 | SlotId: slotId, 19 | DoctorId: doctorId, 20 | Start: start, 21 | Duration: duration, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domain/doctorday/day.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 8 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 9 | "github.com/EventStore/training-introduction-go/eventsourcing" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | const ( 14 | DayCancelledReason = "day cancelled" 15 | ) 16 | 17 | type Day struct { 18 | eventsourcing.AggregateRootSnapshotBase 19 | 20 | isArchived bool 21 | isCancelled bool 22 | isScheduled bool 23 | slots Slots 24 | } 25 | 26 | func NewDay() *Day { 27 | a := &Day{ 28 | AggregateRootSnapshotBase: eventsourcing.NewAggregateRootSnapshot(), 29 | } 30 | 31 | a.Register(events.DayScheduled{}, func(e interface{}) { a.DayScheduled(e.(events.DayScheduled)) }) 32 | a.Register(events.SlotScheduled{}, func(e interface{}) { a.SlotScheduled(e.(events.SlotScheduled)) }) 33 | a.Register(events.SlotBooked{}, func(e interface{}) { a.SlotBooked(e.(events.SlotBooked)) }) 34 | a.Register(events.SlotBookingCancelled{}, func(e interface{}) { a.SlotBookingCancelled(e.(events.SlotBookingCancelled)) }) 35 | a.Register(events.SlotScheduleCancelled{}, func(e interface{}) { a.SlotScheduleCancelled(e.(events.SlotScheduleCancelled)) }) 36 | a.Register(events.DayScheduleCancelled{}, func(e interface{}) { a.DayScheduleCancelled(e.(events.DayScheduleCancelled)) }) 37 | a.Register(events.DayScheduleArchived{}, func(e interface{}) { a.DayScheduleArchived(e.(events.DayScheduleArchived)) }) 38 | a.RegisterSnapshot(func(s interface{}) { a.loadSnapshot(s.(DaySnapshot)) }, a.getSnapshot) 39 | 40 | return a 41 | } 42 | 43 | // Schedule day 44 | 45 | func (s *Day) ScheduleDay(doctorId DoctorID, date time.Time, slots []commands.ScheduledSlot) error { 46 | err := s.isDayCancelledOrArchived() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if s.isScheduled { 52 | return &DayAlreadyScheduledError{} 53 | } 54 | 55 | dayId := NewDayID(doctorId, date) 56 | s.Raise(events.NewDayScheduled(dayId.Value, doctorId.Value, date)) 57 | 58 | for _, slot := range slots { 59 | s.Raise(events.NewSlotScheduled(uuid.New(), dayId.Value, slot.StartTime, slot.Duration)) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (s *Day) DayScheduled(e events.DayScheduled) { 66 | s.Id = NewDayID(NewDoctorID(e.DoctorId), e.Date).Value 67 | s.isScheduled = true 68 | } 69 | 70 | // Schedule slot 71 | 72 | func (s *Day) ScheduleSlot(slotId uuid.UUID, start time.Time, duration time.Duration) error { 73 | err := s.isDayCancelledOrArchived() 74 | if err != nil { 75 | return err 76 | } 77 | err = s.isDayNotScheduled() 78 | if err != nil { 79 | return err 80 | } 81 | 82 | if s.slots.Overlaps(start, duration) { 83 | return &SlotOverlappedError{} 84 | } 85 | 86 | s.Raise(events.NewSlotScheduled(slotId, s.Id, start, duration)) 87 | return nil 88 | } 89 | 90 | func (s *Day) SlotScheduled(e events.SlotScheduled) { 91 | s.slots.Add(e.SlotId, e.StartTime, e.Duration, false) 92 | } 93 | 94 | // Book slot 95 | 96 | func (s *Day) BookSlot(slotId SlotID, patientId PatientID) error { 97 | err := s.isDayCancelledOrArchived() 98 | if err != nil { 99 | return err 100 | } 101 | err = s.isDayNotScheduled() 102 | if err != nil { 103 | return err 104 | } 105 | 106 | slotStatus := s.slots.GetStatus(slotId) 107 | 108 | switch slotStatus { 109 | case SlotAvailable: 110 | s.Raise(events.NewSlotBooked(slotId.Value, s.Id, patientId.Value)) 111 | return nil 112 | case SlotBooked: 113 | return &SlotAlreadyBookedError{} 114 | case SlotNotScheduled: 115 | return &SlotNotScheduledError{} 116 | default: 117 | return fmt.Errorf("invalid slot status: %d", slotStatus) 118 | } 119 | 120 | return fmt.Errorf("unexpected slot booking error") 121 | } 122 | 123 | func (s *Day) SlotBooked(e events.SlotBooked) { 124 | s.slots.MarkAsBooked(NewSlotID(e.SlotId)) 125 | } 126 | 127 | // Cancel slot booking 128 | 129 | func (s *Day) CancelSlotBooking(slotId SlotID, reason string) error { 130 | err := s.isDayCancelledOrArchived() 131 | if err != nil { 132 | return err 133 | } 134 | err = s.isDayNotScheduled() 135 | if err != nil { 136 | return err 137 | } 138 | 139 | if !s.slots.HasBookedSlot(slotId) { 140 | return &SlotNotBookedError{} 141 | } 142 | 143 | s.Raise(events.NewSlotBookingCancelled(slotId.Value, s.Id, reason)) 144 | return nil 145 | } 146 | 147 | func (s *Day) SlotBookingCancelled(e events.SlotBookingCancelled) { 148 | s.slots.MarkAsAvailable(NewSlotID(e.SlotId)) 149 | } 150 | 151 | // Cancel day 152 | 153 | func (s *Day) Cancel() error { 154 | err := s.isDayCancelledOrArchived() 155 | if err != nil { 156 | return err 157 | } 158 | err = s.isDayNotScheduled() 159 | if err != nil { 160 | return err 161 | } 162 | 163 | for _, bookedSlot := range s.slots.GetBookedSlots() { 164 | s.Raise(events.NewSlotBookingCancelled(bookedSlot.Id, s.Id, DayCancelledReason)) 165 | } 166 | 167 | for _, bookedSlot := range s.slots.GetAllSlots() { 168 | s.Raise(events.NewSlotScheduleCancelled(bookedSlot.Id, s.Id)) 169 | } 170 | 171 | s.Raise(events.NewDayScheduleCancelled(s.Id)) 172 | return nil 173 | } 174 | 175 | func (s *Day) SlotScheduleCancelled(e events.SlotScheduleCancelled) { 176 | s.slots.Remove(NewSlotID(e.SlotId)) 177 | } 178 | 179 | func (s *Day) DayScheduleCancelled(_ events.DayScheduleCancelled) { 180 | s.isCancelled = true 181 | } 182 | 183 | // Archive day 184 | 185 | func (s *Day) Archive() error { 186 | err := s.isDayNotScheduled() 187 | if err != nil { 188 | return err 189 | } 190 | 191 | if s.isArchived { 192 | return &DayScheduleAlreadyArchivedError{} 193 | } 194 | 195 | s.Raise(events.NewDayScheduleArchived(s.Id)) 196 | return nil 197 | } 198 | 199 | func (s *Day) DayScheduleArchived(_ events.DayScheduleArchived) { 200 | s.isArchived = true 201 | } 202 | 203 | func (s *Day) isDayCancelledOrArchived() error { 204 | if s.isArchived { 205 | return &DayScheduleAlreadyArchivedError{} 206 | } 207 | 208 | if s.isCancelled { 209 | return &DayScheduleAlreadyCancelledError{} 210 | } 211 | 212 | return nil 213 | } 214 | 215 | func (s *Day) isDayNotScheduled() error { 216 | if !s.isScheduled { 217 | return &DayNotScheduledError{} 218 | } 219 | 220 | return nil 221 | } 222 | 223 | // Snapshot 224 | 225 | func (s *Day) getSnapshot() interface{} { 226 | slots := make([]SlotSnapshot, 0) 227 | for _, slot := range s.slots.GetAllSlots() { 228 | slots = append(slots, NewSlotSnapshot(slot.Id, slot.StartTime, slot.Duration, slot.Booked)) 229 | } 230 | 231 | return NewDaySnapshot(s.isArchived, s.isCancelled, s.isScheduled, slots) 232 | } 233 | 234 | func (s *Day) loadSnapshot(snapshot DaySnapshot) { 235 | s.isArchived = snapshot.IsArchived 236 | s.isCancelled = snapshot.IsCancelled 237 | s.isScheduled = snapshot.IsScheduled 238 | 239 | for _, slot := range snapshot.Slots { 240 | s.slots.Add(slot.Id, slot.Start, slot.Duration, slot.Booked) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /domain/doctorday/day_id.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import "time" 4 | 5 | type DayID struct { 6 | Value string 7 | } 8 | 9 | func NewDayID(doctorId DoctorID, date time.Time) DayID { 10 | return DayID{ 11 | Value: doctorId.Value.String() + "_" + date.Format("2006-01-02"), 12 | } 13 | } 14 | 15 | func NewDayIDFrom(dayId string) DayID { 16 | return DayID{ 17 | Value: dayId, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /domain/doctorday/day_repository.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/infrastructure" 5 | ) 6 | 7 | type DayRepository interface { 8 | Save(day *Day, metadata infrastructure.CommandMetadata) 9 | Get(id DayID) (*Day, error) 10 | } 11 | -------------------------------------------------------------------------------- /domain/doctorday/day_snapshot.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type DaySnapshot struct { 10 | IsArchived bool `json:"isArchived"` 11 | IsCancelled bool `json:"isCancelled"` 12 | IsScheduled bool `json:"isScheduled"` 13 | 14 | Slots []SlotSnapshot `json:"slots"` 15 | } 16 | 17 | func NewDaySnapshot(isArchived, isCancelled, isScheduled bool, slots []SlotSnapshot) DaySnapshot { 18 | return DaySnapshot{ 19 | IsArchived: isArchived, 20 | IsCancelled: isCancelled, 21 | IsScheduled: isScheduled, 22 | Slots: slots, 23 | } 24 | } 25 | 26 | type SlotSnapshot struct { 27 | Id uuid.UUID `json:"id,omitempty"` 28 | Start time.Time `json:"start"` 29 | Duration time.Duration `json:"duration,omitempty"` 30 | Booked bool `json:"booked,omitempty"` 31 | } 32 | 33 | func NewSlotSnapshot(id uuid.UUID, start time.Time, duration time.Duration, booked bool) SlotSnapshot { 34 | return SlotSnapshot{ 35 | Id: id, 36 | Start: start, 37 | Duration: duration, 38 | Booked: booked, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /domain/doctorday/day_snapshot_test.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/EventStore/EventStore-Client-Go/esdb" 8 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 9 | "github.com/EventStore/training-introduction-go/eventsourcing" 10 | "github.com/EventStore/training-introduction-go/infrastructure" 11 | "github.com/google/uuid" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestWriteSnapshotIfThresholdReached(t *testing.T) { 16 | client, err := createESDBClient() 17 | assert.NoError(t, err) 18 | 19 | defer client.Close() 20 | 21 | typeMapper := eventsourcing.NewTypeMapper() 22 | esSerde := infrastructure.NewEsEventSerde(typeMapper) 23 | store := infrastructure.NewEsEventStore(client, "snapshot_test-", esSerde) 24 | aggregateStore := infrastructure.NewEsAggregateStore(store, 5) 25 | RegisterTypes(typeMapper) 26 | 27 | now := time.Now() 28 | tenMinutes := time.Duration(10) * time.Minute 29 | slots := []commands.ScheduledSlot{ 30 | commands.NewScheduledSlot(now, tenMinutes), 31 | commands.NewScheduledSlot(now.Add(tenMinutes), tenMinutes), 32 | commands.NewScheduledSlot(now.Add(tenMinutes*2), tenMinutes), 33 | commands.NewScheduledSlot(now.Add(tenMinutes*3), tenMinutes), 34 | commands.NewScheduledSlot(now.Add(tenMinutes*4), tenMinutes), 35 | } 36 | 37 | aggregate := NewDay() 38 | aggregate.ScheduleDay(NewDoctorID(uuid.New()), now, slots) 39 | 40 | err = aggregateStore.Save(aggregate, infrastructure.NewCommandMetadata(uuid.New(), uuid.New())) 41 | assert.NoError(t, err) 42 | 43 | streamName := eventsourcing.GetStreamName(aggregate) 44 | s, m, err := store.LoadSnapshot(streamName) 45 | assert.NoError(t, err) 46 | assert.NotNil(t, s) 47 | assert.NotNil(t, m) 48 | } 49 | 50 | func TestReadSnapshotWhenLoadingAggregate(t *testing.T) { 51 | client, err := createESDBClient() 52 | assert.NoError(t, err) 53 | 54 | defer client.Close() 55 | 56 | typeMapper := eventsourcing.NewTypeMapper() 57 | esSerde := infrastructure.NewEsEventSerde(typeMapper) 58 | store := infrastructure.NewEsEventStore(client, "snapshot_test-", esSerde) 59 | aggregateStore := infrastructure.NewEsAggregateStore(store, 5) 60 | RegisterTypes(typeMapper) 61 | 62 | now := time.Now() 63 | tenMinutes := time.Duration(10) * time.Minute 64 | slots := []commands.ScheduledSlot{ 65 | commands.NewScheduledSlot(now, tenMinutes), 66 | commands.NewScheduledSlot(now.Add(tenMinutes), tenMinutes), 67 | commands.NewScheduledSlot(now.Add(tenMinutes*2), tenMinutes), 68 | commands.NewScheduledSlot(now.Add(tenMinutes*3), tenMinutes), 69 | commands.NewScheduledSlot(now.Add(tenMinutes*4), tenMinutes), 70 | } 71 | 72 | aggregate := NewDay() 73 | aggregate.ScheduleDay(NewDoctorID(uuid.New()), now, slots) 74 | aggregateChangeCount := len(aggregate.GetChanges()) 75 | 76 | err = aggregateStore.Save(aggregate, infrastructure.NewCommandMetadata(uuid.New(), uuid.New())) 77 | assert.NoError(t, err) 78 | 79 | streamName := eventsourcing.GetStreamName(aggregate) 80 | err = store.TruncateStream(streamName, uint64(aggregateChangeCount)) 81 | assert.NoError(t, err) 82 | 83 | reloadedAggregate := NewDay() 84 | err = aggregateStore.Load(aggregate.Id, reloadedAggregate) 85 | assert.NoError(t, err) 86 | assert.Equal(t, 5, reloadedAggregate.GetVersion()) 87 | } 88 | 89 | func createESDBClient() (*esdb.Client, error) { 90 | settings, err := esdb.ParseConnectionString("esdb://localhost:2113?tls=false") 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | db, err := esdb.NewClient(settings) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | return db, nil 101 | } 102 | -------------------------------------------------------------------------------- /domain/doctorday/day_test.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 8 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 9 | "github.com/EventStore/training-introduction-go/infrastructure" 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestDayAggregate(t *testing.T) { 15 | doctorId := NewDoctorID(uuid.New()) 16 | date := time.Date(2020, 5, 2, 10, 0, 0, 0, time.Local) 17 | store := infrastructure.NewFakeAggregateStore() 18 | registry := NewEventStoreDayRepository(store) 19 | a := DayTests{ 20 | AggregateTests: infrastructure.NewAggregateTests(store), 21 | 22 | doctorId: doctorId, 23 | patientId: NewPatientID("John Doe"), 24 | date: date, 25 | dayId: NewDayID(doctorId, date), 26 | tenMinutes: time.Minute * time.Duration(10), 27 | } 28 | 29 | a.RegisterHandlers(NewHandlers(registry)) 30 | 31 | t.Run("ShouldBeScheduled", a.ShouldBeScheduled) 32 | t.Run("ShouldNotBeScheduledTwice", a.ShouldNotBeScheduledTwice) 33 | t.Run("ShouldAllowToBookSlot", a.ShouldAllowToBookSlot) 34 | t.Run("ShouldNotAllowToBookSlotTwice", a.ShouldNotAllowToBookSlotTwice) 35 | t.Run("ShouldNotAllowToBookSlotIfDayNotScheduled", a.ShouldNotAllowToBookSlotIfDayNotScheduled) 36 | t.Run("ShouldNotAllowToBookAnUnscheduledSlot", a.ShouldNotAllowToBookAnUnscheduledSlot) 37 | t.Run("AllowToCancelBooking", a.AllowToCancelBooking) 38 | t.Run("NotAllowToCancelUnbookedSlot", a.NotAllowToCancelUnbookedSlot) 39 | t.Run("AllowToScheduleAnExtraSlot", a.AllowToScheduleAnExtraSlot) 40 | t.Run("DontAllowSchedulingOverlappingSlots", a.DontAllowSchedulingOverlappingSlots) 41 | t.Run("AllowToScheduleAdjacentSlots", a.AllowToScheduleAdjacentSlots) 42 | t.Run("CancelBookedSlotsWhenDayIsCancelled", a.CancelBookedSlotsWhenDayIsCancelled) 43 | t.Run("ArchiveScheduledDay", a.ArchiveScheduledDay) 44 | } 45 | 46 | func (t *DayTests) ShouldBeScheduled(tt *testing.T) { 47 | var slots []commands.ScheduledSlot 48 | slots = make([]commands.ScheduledSlot, 30) 49 | tenMinutes := time.Minute * 10 50 | for i, _ := range slots { 51 | slots[i] = commands.NewScheduledSlot(t.date.Add(tenMinutes*time.Duration(i)), tenMinutes) 52 | } 53 | 54 | t.Given() 55 | t.When(commands.NewScheduleDay(t.doctorId.Value, t.date, slots)) 56 | t.Then(func(changes []interface{}, err error) { 57 | assert.NoError(tt, err) 58 | assert.Equal(tt, events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), changes[0]) 59 | assert.Len(tt, changes, 31) 60 | }) 61 | } 62 | 63 | func (t *DayTests) ShouldNotBeScheduledTwice(tt *testing.T) { 64 | var slots []commands.ScheduledSlot 65 | slots = make([]commands.ScheduledSlot, 30) 66 | tenMinutes := time.Minute * 10 67 | for i, _ := range slots { 68 | slots[i] = commands.NewScheduledSlot(t.date.Add(tenMinutes*time.Duration(i)), tenMinutes) 69 | } 70 | 71 | t.Given(events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date)) 72 | t.When(commands.NewScheduleDay(t.doctorId.Value, t.date, slots)) 73 | t.ThenExpectError(tt, &DayAlreadyScheduledError{}) 74 | } 75 | 76 | func (t *DayTests) ShouldAllowToBookSlot(tt *testing.T) { 77 | slotId := NewSlotID(uuid.New()) 78 | expected := events.NewSlotBooked(slotId.Value, t.dayId.Value, "John Doe") 79 | 80 | t.Given( 81 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 82 | events.NewSlotScheduled(slotId.Value, t.dayId.Value, t.date, time.Minute*10)) 83 | t.When(commands.NewBookSlot(slotId.Value, t.dayId.Value, "John Doe")) 84 | t.ThenExpectSingleChange(tt, expected) 85 | } 86 | 87 | func (t *DayTests) ShouldNotAllowToBookSlotTwice(tt *testing.T) { 88 | slotId := NewSlotID(uuid.New()) 89 | 90 | t.Given( 91 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 92 | events.NewSlotScheduled(slotId.Value, t.dayId.Value, t.date, time.Minute*10), 93 | events.NewSlotBooked(slotId.Value, t.dayId.Value, "John Doe")) 94 | t.When(commands.NewBookSlot(slotId.Value, t.dayId.Value, "John Doe")) 95 | t.ThenExpectError(tt, &SlotAlreadyBookedError{}) 96 | } 97 | 98 | func (t *DayTests) ShouldNotAllowToBookSlotIfDayNotScheduled(tt *testing.T) { 99 | slotId := NewSlotID(uuid.New()) 100 | 101 | t.Given() 102 | t.When(commands.NewBookSlot(slotId.Value, t.dayId.Value, "John Doe")) 103 | t.ThenExpectError(tt, &DayNotScheduledError{}) 104 | } 105 | 106 | func (t *DayTests) ShouldNotAllowToBookAnUnscheduledSlot(tt *testing.T) { 107 | slotId := NewSlotID(uuid.New()) 108 | 109 | t.Given(events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date)) 110 | t.When(commands.NewBookSlot(slotId.Value, t.dayId.Value, "John Doe")) 111 | t.ThenExpectError(tt, &SlotNotScheduledError{}) 112 | } 113 | 114 | func (t *DayTests) AllowToCancelBooking(tt *testing.T) { 115 | slotId := NewSlotID(uuid.New()) 116 | reason := "Cancel reason" 117 | expected := events.NewSlotBookingCancelled(slotId.Value, t.dayId.Value, reason) 118 | 119 | t.Given( 120 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 121 | events.NewSlotScheduled(slotId.Value, t.dayId.Value, t.date, time.Minute*10), 122 | events.NewSlotBooked(slotId.Value, t.dayId.Value, "John Doe")) 123 | t.When(commands.NewCancelSlotBooking(slotId.Value, t.dayId.Value, reason)) 124 | t.ThenExpectSingleChange(tt, expected) 125 | } 126 | 127 | func (t *DayTests) NotAllowToCancelUnbookedSlot(tt *testing.T) { 128 | slotId := NewSlotID(uuid.New()) 129 | 130 | t.Given( 131 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 132 | events.NewSlotScheduled(slotId.Value, t.dayId.Value, t.date, time.Minute*10)) 133 | t.When(commands.NewCancelSlotBooking(slotId.Value, t.dayId.Value, "Some reason")) 134 | t.ThenExpectError(tt, &SlotNotBookedError{}) 135 | } 136 | 137 | func (t *DayTests) AllowToScheduleAnExtraSlot(tt *testing.T) { 138 | slotId := NewSlotID(uuid.New()) 139 | expected := events.NewSlotScheduled(slotId.Value, t.dayId.Value, t.date, t.tenMinutes) 140 | 141 | t.Given( 142 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 143 | /* events.NewSlotScheduled(slotId.Value, t.dayId.Value, t.date, time.Minute*10) */) 144 | t.When(commands.NewScheduleSlot(slotId.Value, t.doctorId.Value, t.date, t.tenMinutes)) 145 | t.ThenExpectSingleChange(tt, expected) 146 | } 147 | 148 | func (t *DayTests) DontAllowSchedulingOverlappingSlots(tt *testing.T) { 149 | slotOneId := NewSlotID(uuid.New()) 150 | slotTwoId := NewSlotID(uuid.New()) 151 | 152 | t.Given( 153 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 154 | events.NewSlotScheduled(slotOneId.Value, t.dayId.Value, t.date, t.tenMinutes)) 155 | t.When(commands.NewScheduleSlot(slotTwoId.Value, t.doctorId.Value, t.date, t.tenMinutes)) 156 | t.ThenExpectError(tt, &SlotOverlappedError{}) 157 | } 158 | 159 | func (t *DayTests) AllowToScheduleAdjacentSlots(tt *testing.T) { 160 | slotOneId := NewSlotID(uuid.New()) 161 | slotTwoId := NewSlotID(uuid.New()) 162 | expected := events.NewSlotScheduled(slotTwoId.Value, t.dayId.Value, t.date.Add(t.tenMinutes), t.tenMinutes) 163 | 164 | t.Given( 165 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 166 | events.NewSlotScheduled(slotOneId.Value, t.dayId.Value, t.date, t.tenMinutes)) 167 | t.When(commands.NewScheduleSlot(slotTwoId.Value, t.doctorId.Value, t.date.Add(t.tenMinutes), t.tenMinutes)) 168 | t.ThenExpectSingleChange(tt, expected) 169 | } 170 | 171 | func (t *DayTests) CancelBookedSlotsWhenDayIsCancelled(tt *testing.T) { 172 | slotOneId := NewSlotID(uuid.New()) 173 | slotTwoId := NewSlotID(uuid.New()) 174 | 175 | t.Given( 176 | events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date), 177 | events.NewSlotScheduled(slotOneId.Value, t.dayId.Value, t.date, t.tenMinutes), 178 | events.NewSlotScheduled(slotTwoId.Value, t.dayId.Value, t.date.Add(t.tenMinutes), t.tenMinutes), 179 | events.NewSlotBooked(slotOneId.Value, t.dayId.Value, t.patientId.Value)) 180 | t.When(commands.NewCancelDaySchedule(t.dayId.Value)) 181 | t.ThenExpectChanges(tt, []interface{}{ 182 | events.NewSlotBookingCancelled(slotOneId.Value, t.dayId.Value, DayCancelledReason), 183 | events.NewSlotScheduleCancelled(slotOneId.Value, t.dayId.Value), 184 | events.NewSlotScheduleCancelled(slotTwoId.Value, t.dayId.Value), 185 | events.NewDayScheduleCancelled(t.dayId.Value), 186 | }) 187 | } 188 | 189 | func (t *DayTests) ArchiveScheduledDay(tt *testing.T) { 190 | expected := events.NewDayScheduleArchived(t.dayId.Value) 191 | 192 | t.Given(events.NewDayScheduled(t.dayId.Value, t.doctorId.Value, t.date)) 193 | t.When(commands.NewArchiveDaySchedule(t.dayId.Value)) 194 | t.ThenExpectSingleChange(tt, expected) 195 | } 196 | 197 | type DayTests struct { 198 | infrastructure.AggregateTests 199 | 200 | dayId DayID 201 | doctorId DoctorID 202 | patientId PatientID 203 | date time.Time 204 | tenMinutes time.Duration 205 | } 206 | -------------------------------------------------------------------------------- /domain/doctorday/doctor_id.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import "github.com/google/uuid" 4 | 5 | type DoctorID struct { 6 | Value uuid.UUID 7 | } 8 | 9 | func NewDoctorID(id uuid.UUID) DoctorID { 10 | return DoctorID{ 11 | Value: id, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /domain/doctorday/errors.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import "fmt" 4 | 5 | type DayAlreadyScheduledError struct{} 6 | 7 | func (e DayAlreadyScheduledError) Error() string { 8 | return fmt.Sprintf("day already scheduled error") 9 | } 10 | 11 | type DayNotScheduledError struct{} 12 | 13 | func (e DayNotScheduledError) Error() string { 14 | return fmt.Sprintf("day not scheduled error") 15 | } 16 | 17 | type DayScheduleAlreadyArchivedError struct{} 18 | 19 | func (e DayScheduleAlreadyArchivedError) Error() string { 20 | return fmt.Sprintf("day schedule already archived error") 21 | } 22 | 23 | type DayScheduleAlreadyCancelledError struct{} 24 | 25 | func (e DayScheduleAlreadyCancelledError) Error() string { 26 | return fmt.Sprintf("day schedule already cancelled error") 27 | } 28 | 29 | type SlotAlreadyBookedError struct{} 30 | 31 | func (e SlotAlreadyBookedError) Error() string { 32 | return fmt.Sprintf("slot already booked error") 33 | } 34 | 35 | type SlotNotBookedError struct{} 36 | 37 | func (e SlotNotBookedError) Error() string { 38 | return fmt.Sprintf("slot not booked error") 39 | } 40 | 41 | type SlotNotScheduledError struct{} 42 | 43 | func (e SlotNotScheduledError) Error() string { 44 | return fmt.Sprintf("slot not scheduled error") 45 | } 46 | 47 | type SlotOverlappedError struct{} 48 | 49 | func (e SlotOverlappedError) Error() string { 50 | return fmt.Sprintf("slot overlapped error") 51 | } 52 | -------------------------------------------------------------------------------- /domain/doctorday/event_store_day_repo.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import "github.com/EventStore/training-introduction-go/infrastructure" 4 | 5 | type EventStoreDayRepository struct { 6 | aggregateStore infrastructure.AggregateStore 7 | } 8 | 9 | func NewEventStoreDayRepository(store infrastructure.AggregateStore) *EventStoreDayRepository { 10 | return &EventStoreDayRepository{ 11 | aggregateStore: store, 12 | } 13 | } 14 | 15 | func (r *EventStoreDayRepository) Save(day *Day, m infrastructure.CommandMetadata) { 16 | r.aggregateStore.Save(day, m) 17 | } 18 | 19 | func (r *EventStoreDayRepository) Get(id DayID) (*Day, error) { 20 | day := NewDay() 21 | err := r.aggregateStore.Load(id.Value, day) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return day, nil 27 | } 28 | -------------------------------------------------------------------------------- /domain/doctorday/events/calendar_day_started.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "time" 4 | 5 | type CalendarDayStarted struct { 6 | Date time.Time 7 | } 8 | 9 | func NewCalendarDayStarted(date time.Time) CalendarDayStarted { 10 | return CalendarDayStarted{ 11 | Date: date, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /domain/doctorday/events/day_schedule_archived.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | 4 | type DayScheduleArchived struct { 5 | DayId string 6 | } 7 | 8 | func NewDayScheduleArchived(dayId string) DayScheduleArchived { 9 | return DayScheduleArchived{ 10 | DayId: dayId, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /domain/doctorday/events/day_schedule_cancelled.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | type DayScheduleCancelled struct { 4 | DayId string 5 | } 6 | 7 | func NewDayScheduleCancelled(dayId string) DayScheduleCancelled { 8 | return DayScheduleCancelled{ 9 | DayId: dayId, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /domain/doctorday/events/day_scheduled.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type DayScheduled struct { 10 | DayId string `json:"dayId"` 11 | DoctorId uuid.UUID `json:"doctorId"` 12 | Date time.Time `json:"date"` 13 | } 14 | 15 | func NewDayScheduled(dayId string, doctorId uuid.UUID, date time.Time) DayScheduled { 16 | return DayScheduled{ 17 | DayId: dayId, 18 | DoctorId: doctorId, 19 | Date: date, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /domain/doctorday/events/slot_booked.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "github.com/google/uuid" 4 | 5 | type SlotBooked struct { 6 | DayId string `json:"dayId"` 7 | SlotId uuid.UUID `json:"slotId"` 8 | PatientId string `json:"patientId"` 9 | } 10 | 11 | func NewSlotBooked(slotId uuid.UUID, dayId, patientId string) SlotBooked { 12 | return SlotBooked{ 13 | DayId: dayId, 14 | SlotId: slotId, 15 | PatientId: patientId, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /domain/doctorday/events/slot_booking_cancelled.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "github.com/google/uuid" 4 | 5 | type SlotBookingCancelled struct { 6 | DayId string `json:"dayId"` 7 | SlotId uuid.UUID `json:"slotId"` 8 | Reason string `json:"reason"` 9 | } 10 | 11 | func NewSlotBookingCancelled(slotId uuid.UUID, dayId, reason string) SlotBookingCancelled { 12 | return SlotBookingCancelled{ 13 | DayId: dayId, 14 | SlotId: slotId, 15 | Reason: reason, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /domain/doctorday/events/slot_schedule_cancelled.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "github.com/google/uuid" 4 | 5 | type SlotScheduleCancelled struct { 6 | DayId string `json:"dayId"` 7 | SlotId uuid.UUID `json:"slotId"` 8 | } 9 | 10 | func NewSlotScheduleCancelled(slotId uuid.UUID, dayId string) SlotScheduleCancelled { 11 | return SlotScheduleCancelled{ 12 | DayId: dayId, 13 | SlotId: slotId, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /domain/doctorday/events/slot_scheduled.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type SlotScheduled struct { 10 | SlotId uuid.UUID `json:"slotId"` 11 | DayId string `json:"dayId"` 12 | StartTime time.Time `json:"startTime"` 13 | Duration time.Duration `json:"duration"` 14 | } 15 | 16 | func NewSlotScheduled(slotId uuid.UUID, dayId string, start time.Time, duration time.Duration) SlotScheduled { 17 | return SlotScheduled{ 18 | SlotId: slotId, 19 | DayId: dayId, 20 | StartTime: start, 21 | Duration: duration, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /domain/doctorday/patient_id.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | type PatientID struct { 4 | Value string 5 | } 6 | 7 | func NewPatientID(id string) PatientID { 8 | return PatientID{ 9 | Value: id, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /domain/doctorday/slot.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type Slot struct { 10 | Id uuid.UUID 11 | StartTime time.Time 12 | Duration time.Duration 13 | Booked bool 14 | } 15 | 16 | func NewSlot(id uuid.UUID, start time.Time, duration time.Duration, booked bool) *Slot { 17 | return &Slot{ 18 | Id: id, 19 | StartTime: start, 20 | Duration: duration, 21 | Booked: booked, 22 | } 23 | } 24 | 25 | func (s *Slot) Book() { 26 | s.Booked = true 27 | } 28 | 29 | func (s *Slot) Cancel() { 30 | s.Booked = true 31 | } 32 | 33 | func (s *Slot) Overlaps(start time.Time, duration time.Duration) bool { 34 | thisStart := s.StartTime 35 | thisEnd := s.StartTime.Add(s.Duration) 36 | proposedStart := start 37 | proposedEnd := proposedStart.Add(duration) 38 | 39 | return thisStart.Before(proposedEnd) && thisEnd.After(proposedStart) 40 | } 41 | -------------------------------------------------------------------------------- /domain/doctorday/slot_id.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import "github.com/google/uuid" 4 | 5 | type SlotID struct { 6 | Value uuid.UUID 7 | } 8 | 9 | func NewSlotID(id uuid.UUID) SlotID { 10 | return SlotID{ 11 | Value: id, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /domain/doctorday/slot_status.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | type SlotStatus int 4 | 5 | const ( 6 | SlotNotScheduled SlotStatus = iota 7 | SlotAvailable 8 | SlotBooked 9 | ) 10 | -------------------------------------------------------------------------------- /domain/doctorday/slots.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type Slots struct { 10 | slots []*Slot 11 | } 12 | 13 | func (s *Slots) Add(id uuid.UUID, start time.Time, duration time.Duration, booked bool) { 14 | s.slots = append(s.slots, NewSlot(id, start, duration, booked)) 15 | } 16 | 17 | func (s *Slots) Remove(id SlotID) { 18 | slots := make([]*Slot, 0) 19 | for _, slot := range s.slots { 20 | if slot.Id != id.Value { 21 | slots = append(slots, slot) 22 | } 23 | } 24 | 25 | s.slots = slots 26 | } 27 | 28 | func (s *Slots) Overlaps(start time.Time, duration time.Duration) bool { 29 | for _, slot := range s.slots { 30 | if slot.Overlaps(start, duration) { 31 | return true 32 | } 33 | } 34 | 35 | return false 36 | } 37 | 38 | func (s *Slots) GetStatus(id SlotID) SlotStatus { 39 | slot := s.getSlot(id) 40 | if slot == nil { 41 | return SlotNotScheduled 42 | } 43 | 44 | if slot.Booked { 45 | return SlotBooked 46 | } 47 | 48 | return SlotAvailable 49 | } 50 | 51 | func (s *Slots) MarkAsBooked(id SlotID) { 52 | slot := s.getSlot(id) 53 | if slot != nil { 54 | slot.Book() 55 | } 56 | } 57 | 58 | func (s *Slots) MarkAsAvailable(id SlotID) { 59 | slot := s.getSlot(id) 60 | if slot != nil { 61 | slot.Cancel() 62 | } 63 | } 64 | 65 | func (s *Slots) HasBookedSlot(id SlotID) bool { 66 | slot := s.getSlot(id) 67 | if slot == nil { 68 | return false 69 | } 70 | 71 | return slot.Booked 72 | } 73 | 74 | func (s *Slots) GetBookedSlots() []*Slot { 75 | bookedSlots := make([]*Slot, 0) 76 | for _, slot := range s.slots { 77 | if slot.Booked { 78 | bookedSlots = append(bookedSlots, slot) 79 | } 80 | } 81 | 82 | return bookedSlots 83 | } 84 | 85 | func (s *Slots) GetAllSlots() []*Slot { 86 | return s.slots 87 | } 88 | 89 | func (s *Slots) getSlot(id SlotID) *Slot { 90 | for _, slot := range s.slots { 91 | if slot.Id == id.Value { 92 | return slot 93 | } 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /domain/doctorday/type_mapping.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/EventStore/training-introduction-go/domain/doctorday/commands" 7 | "github.com/EventStore/training-introduction-go/domain/doctorday/events" 8 | "github.com/EventStore/training-introduction-go/eventsourcing" 9 | "github.com/EventStore/training-introduction-go/infrastructure" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | const Prefix = "doctorday" 14 | 15 | func RegisterTypes(tm *eventsourcing.TypeMapper) { 16 | mustParseDate := func(s string) time.Time { 17 | t, _ := time.Parse("2006-01-02", s) 18 | return t 19 | } 20 | mustParseTime := func(s string) time.Time { 21 | t, _ := time.Parse(time.RFC3339, s) 22 | return t 23 | } 24 | mustParseDuration := func(s string) time.Duration { 25 | d, _ := time.ParseDuration(s) 26 | return d 27 | } 28 | 29 | tm.MapEvent(infrastructure.GetValueType(events.DayScheduled{}), Prefix+"-day-scheduled", 30 | func(d map[string]interface{}) interface{} { 31 | return events.NewDayScheduled( 32 | d["dayId"].(string), 33 | uuid.MustParse(d["doctorId"].(string)), 34 | mustParseTime(d["date"].(string))) 35 | }, 36 | func(v interface{}) map[string]interface{} { 37 | t := v.(events.DayScheduled) 38 | return map[string]interface{}{ 39 | "dayId": t.DayId, 40 | "doctorId": t.DoctorId, 41 | "date": t.Date, 42 | } 43 | }) 44 | 45 | tm.MapEvent(infrastructure.GetValueType(events.SlotScheduled{}), Prefix+"-slot-scheduled", 46 | func(d map[string]interface{}) interface{} { 47 | return events.NewSlotScheduled( 48 | uuid.MustParse(d["slotId"].(string)), 49 | d["dayId"].(string), 50 | mustParseTime(d["startTime"].(string)), 51 | mustParseDuration(d["duration"].(string))) 52 | }, 53 | func(v interface{}) map[string]interface{} { 54 | t := v.(events.SlotScheduled) 55 | return map[string]interface{}{ 56 | "slotId": t.SlotId, 57 | "dayId": t.DayId, 58 | "startTime": t.StartTime, 59 | "duration": t.Duration.String(), 60 | } 61 | }) 62 | 63 | tm.MapEvent(infrastructure.GetValueType(events.SlotBooked{}), Prefix+"-slot-booked", 64 | func(d map[string]interface{}) interface{} { 65 | return events.NewSlotBooked( 66 | uuid.MustParse(d["slotId"].(string)), 67 | d["dayId"].(string), 68 | d["patientId"].(string)) 69 | }, 70 | func(v interface{}) map[string]interface{} { 71 | t := v.(events.SlotBooked) 72 | return map[string]interface{}{ 73 | "slotId": t.SlotId, 74 | "dayId": t.DayId, 75 | "patientId": t.PatientId, 76 | } 77 | }) 78 | 79 | tm.MapEvent(infrastructure.GetValueType(events.SlotBookingCancelled{}), Prefix+"-slot-booking-cancelled", 80 | func(d map[string]interface{}) interface{} { 81 | return events.NewSlotBookingCancelled( 82 | uuid.MustParse(d["slotId"].(string)), 83 | d["dayId"].(string), 84 | d["reason"].(string)) 85 | }, 86 | func(v interface{}) map[string]interface{} { 87 | t := v.(events.SlotBookingCancelled) 88 | return map[string]interface{}{ 89 | "slotId": t.SlotId, 90 | "dayId": t.DayId, 91 | "reason": t.Reason, 92 | } 93 | }) 94 | 95 | tm.MapEvent(infrastructure.GetValueType(events.SlotScheduleCancelled{}), Prefix+"-slot-schedule-cancelled", 96 | func(d map[string]interface{}) interface{} { 97 | return events.NewSlotScheduleCancelled( 98 | uuid.MustParse(d["slotId"].(string)), 99 | d["dayId"].(string)) 100 | }, 101 | func(v interface{}) map[string]interface{} { 102 | t := v.(events.SlotScheduleCancelled) 103 | return map[string]interface{}{ 104 | "slotId": t.SlotId, 105 | "dayId": t.DayId, 106 | } 107 | }) 108 | 109 | tm.MapEvent(infrastructure.GetValueType(events.DayScheduleCancelled{}), Prefix+"-day-schedule-cancelled", 110 | func(d map[string]interface{}) interface{} { 111 | return events.NewDayScheduleCancelled( 112 | d["dayId"].(string)) 113 | }, 114 | func(v interface{}) map[string]interface{} { 115 | t := v.(events.DayScheduleCancelled) 116 | return map[string]interface{}{ 117 | "dayId": t.DayId, 118 | } 119 | }) 120 | 121 | tm.MapEvent(infrastructure.GetValueType(events.DayScheduleArchived{}), Prefix+"-day-schedule-archived", 122 | func(d map[string]interface{}) interface{} { 123 | return events.NewDayScheduleArchived( 124 | d["dayId"].(string)) 125 | }, 126 | func(v interface{}) map[string]interface{} { 127 | t := v.(events.DayScheduleArchived) 128 | return map[string]interface{}{ 129 | "dayId": t.DayId, 130 | } 131 | }) 132 | 133 | tm.MapEvent(infrastructure.GetValueType(events.CalendarDayStarted{}), Prefix+"-calendar-day-started", 134 | func(d map[string]interface{}) interface{} { 135 | return events.NewCalendarDayStarted( 136 | mustParseDate(d["date"].(string))) 137 | }, 138 | func(v interface{}) map[string]interface{} { 139 | t := v.(events.CalendarDayStarted) 140 | return map[string]interface{}{ 141 | "date": t.Date, 142 | } 143 | }) 144 | 145 | registerType := func(t interface{}, typeName string) { 146 | tm.RegisterType(infrastructure.GetValueType(t), typeName, func() interface{} { 147 | return t 148 | }) 149 | } 150 | 151 | // Commands 152 | registerType(commands.ArchiveDaySchedule{}, Prefix+"-archive-day-schedule") 153 | registerType(commands.BookSlot{}, Prefix+"-book-slot") 154 | registerType(commands.CancelDaySchedule{}, Prefix+"-cancel-day-schedule") 155 | registerType(commands.CancelSlotBooking{}, Prefix+"-cancel-slot-booking") 156 | registerType(commands.ScheduleDay{}, Prefix+"-schedule-day") 157 | registerType(commands.ScheduledSlot{}, Prefix+"-schedule-slot") 158 | 159 | // Snapshots 160 | registerType(DaySnapshot{}, "doctor-day-snapshot") 161 | } 162 | -------------------------------------------------------------------------------- /domain/doctorday/type_mapping_test.go: -------------------------------------------------------------------------------- 1 | package doctorday 2 | // 3 | //import ( 4 | // "testing" 5 | // 6 | // "github.com/EventStore/training-introduction-go/domain/doctorday/events" 7 | // "github.com/EventStore/training-introduction-go/eventsourcing" 8 | // "github.com/google/uuid" 9 | // "github.com/stretchr/testify/assert" 10 | //) 11 | // 12 | //func TestCheckSlotBookingCancelledCorrectlyMapsWithDefaultValue(t *testing.T) { 13 | // typeMapper := eventsourcing.NewTypeMapper() 14 | // RegisterTypes(typeMapper) 15 | // 16 | // dataToType, err := typeMapper.GetDataToType("doctorday-slot-booking-cancelled") 17 | // assert.NoError(t, err) 18 | // 19 | // slotId := uuid.New() 20 | // slotBookingCancelled := dataToType(map[string]interface{}{ 21 | // "dayId": "dayId", 22 | // "slotId": slotId.String(), 23 | // "reason": "reason", 24 | // }) 25 | // 26 | // assert.NotNil(t, slotBookingCancelled) 27 | // assert.IsType(t, events.SlotBookingCancelled{}, slotBookingCancelled) 28 | // assert.Equal(t, events.NewSlotBookingCancelled(slotId, "dayId", "reason", "unknown request"), slotBookingCancelled) 29 | //} 30 | // 31 | //func TestCheckSlotBookingCancelledCorrectlyMapsWithValuePresent(t *testing.T) { 32 | // typeMapper := eventsourcing.NewTypeMapper() 33 | // RegisterTypes(typeMapper) 34 | // 35 | // dataToType, err := typeMapper.GetDataToType("doctorday-slot-booking-cancelled") 36 | // assert.NoError(t, err) 37 | // 38 | // slotId := uuid.New() 39 | // slotBookingCancelled := dataToType(map[string]interface{}{ 40 | // "dayId": "dayId", 41 | // "slotId": slotId.String(), 42 | // "reason": "reason", 43 | // "requestedBy": "doctor", 44 | // }) 45 | // 46 | // assert.NotNil(t, slotBookingCancelled) 47 | // assert.IsType(t, events.SlotBookingCancelled{}, slotBookingCancelled) 48 | // assert.Equal(t, events.NewSlotBookingCancelled(slotId, "dayId", "reason", "doctor"), slotBookingCancelled) 49 | //} 50 | -------------------------------------------------------------------------------- /domain/readmodel/archivable_day.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | import "time" 4 | 5 | type ArchivableDay struct { 6 | Id string 7 | Date time.Time 8 | } 9 | 10 | func NewArchivableDay(id string, d time.Time) ArchivableDay { 11 | return ArchivableDay{ 12 | Id: id, 13 | Date: d, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /domain/readmodel/archivable_day_repository.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | import "time" 4 | 5 | type ArchivableDaysRepository interface { 6 | Add(day ArchivableDay) error 7 | FindAll(dateThreshold time.Time) ([]ArchivableDay, error) 8 | } 9 | -------------------------------------------------------------------------------- /domain/readmodel/available_slot.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | import "time" 4 | 5 | type AvailableSlot struct { 6 | Id string `json:"id"` 7 | DayId string `json:"dayId"` 8 | Date string `json:"date"` 9 | StartTime string `json:"startTime"` 10 | Duration time.Duration `json:"duration"` 11 | } 12 | 13 | func NewAvailableSlot(id, dayId, date, startTime string, d time.Duration) AvailableSlot { 14 | return AvailableSlot{ 15 | Id: id, 16 | DayId: dayId, 17 | Date: date, 18 | StartTime: startTime, 19 | Duration: d, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /domain/readmodel/available_slots_repository.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | import "time" 4 | 5 | type AvailableSlotsRepository interface { 6 | GetSlotsAvailableOn(time time.Time) ([]AvailableSlot, error) 7 | } 8 | -------------------------------------------------------------------------------- /domain/readmodel/booked_slot.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | type BookedSlot struct { 4 | SlotId string 5 | DayId string 6 | Month int 7 | PatientId string 8 | IsBooked bool 9 | } 10 | 11 | func NewBookedSlot(slotId, dayId string, month int) BookedSlot { 12 | return BookedSlot{ 13 | SlotId: slotId, 14 | DayId: dayId, 15 | Month: month, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /domain/readmodel/booked_slots_repository.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | type BookedSlotsRepository interface { 4 | AddSlot(s BookedSlot) error 5 | CountByPatientAndMonth(patientId string, month int) (int, error) 6 | MarkSlotAsAvailable(slotId string) error 7 | MarkSlotAsBooked(slotId, patientId string) error 8 | GetSlot(slotId string) (BookedSlot, error) 9 | } 10 | -------------------------------------------------------------------------------- /domain/readmodel/scheduled_slot.go: -------------------------------------------------------------------------------- 1 | package readmodel 2 | 3 | import "time" 4 | 5 | type ScheduledSlot struct { 6 | ScheduledSlotId string 7 | StartTime time.Time 8 | Duration time.Duration 9 | } 10 | 11 | func NewScheduledSlot(scheduledSlotId string, startTime time.Time, duration time.Duration) ScheduledSlot { 12 | return ScheduledSlot{ 13 | ScheduledSlotId: scheduledSlotId, 14 | StartTime: startTime, 15 | Duration: duration, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /eventsourcing/aggregate_root.go: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | type AggregateRoot interface { 8 | Load(events []interface{}) 9 | ClearChanges() 10 | GetChanges() []interface{} 11 | GetId() string 12 | GetVersion() int 13 | } 14 | 15 | type AggregateRootBase struct { 16 | AggregateRoot 17 | 18 | Id string 19 | version int 20 | changes []interface{} 21 | handlers map[reflect.Type]func(interface{}) 22 | } 23 | 24 | func NewAggregateRoot() AggregateRootBase { 25 | return AggregateRootBase{ 26 | version: -1, 27 | changes: make([]interface{}, 0), 28 | handlers: make(map[reflect.Type]func(interface{})), 29 | } 30 | } 31 | 32 | func GetStreamName(a AggregateRoot) string { 33 | return GetStreamNameWithId(a, a.GetId()) 34 | } 35 | 36 | func GetStreamNameWithId(a AggregateRoot, id string) string { 37 | return getValueType(a).String() + "-" + id 38 | } 39 | 40 | // use local copy to prevent package import cycle 41 | func getValueType(t interface{}) reflect.Type { 42 | v := reflect.ValueOf(t) 43 | if v.Kind() == reflect.Ptr { 44 | v = v.Elem() 45 | } 46 | return v.Type() 47 | } 48 | 49 | func (a *AggregateRootBase) Register(event interface{}, handler func(interface{})) { 50 | a.handlers[getValueType(event)] = handler 51 | } 52 | 53 | func (a *AggregateRootBase) Load(events []interface{}) { 54 | for _, event := range events { 55 | a.Raise(event) 56 | a.version++ 57 | } 58 | } 59 | 60 | func (a *AggregateRootBase) Raise(event interface{}) { 61 | if handler, exists := a.handlers[getValueType(event)]; exists { 62 | handler(event) 63 | a.changes = append(a.changes, event) 64 | } 65 | } 66 | 67 | func (a *AggregateRootBase) ClearChanges() { 68 | a.changes = []interface{}{} 69 | } 70 | 71 | func (a *AggregateRootBase) GetChanges() []interface{} { 72 | return a.changes 73 | } 74 | 75 | func (a *AggregateRootBase) GetId() string { 76 | return a.Id 77 | } 78 | 79 | func (a *AggregateRootBase) GetVersion() int { 80 | return a.version 81 | } 82 | -------------------------------------------------------------------------------- /eventsourcing/aggregate_snapshot.go: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | type AggregateRootSnapshot interface { 4 | GetSnapshot() interface{} 5 | GetSnapshotVersion() int 6 | LoadSnapshot(snapshot interface{}, version int) 7 | } 8 | 9 | type AggregateRootSnapshotBase struct { 10 | AggregateRootBase 11 | AggregateRootSnapshot 12 | 13 | snapshotVersion int 14 | loadSnapshot func(snapshot interface{}) 15 | getSnapshot func() interface{} 16 | } 17 | 18 | func NewAggregateRootSnapshot() AggregateRootSnapshotBase { 19 | return AggregateRootSnapshotBase{ 20 | AggregateRootBase: NewAggregateRoot(), 21 | } 22 | } 23 | 24 | func (a *AggregateRootSnapshotBase) RegisterSnapshot(load func(snapshot interface{}), get func() interface{}) { 25 | a.loadSnapshot = load 26 | a.getSnapshot = get 27 | } 28 | 29 | func (a *AggregateRootSnapshotBase) LoadSnapshot(snapshot interface{}, version int) { 30 | a.loadSnapshot(snapshot) 31 | a.version = version 32 | a.snapshotVersion = version 33 | } 34 | 35 | func (a *AggregateRootSnapshotBase) GetSnapshot() interface{} { 36 | return a.getSnapshot() 37 | } 38 | 39 | func (a *AggregateRootSnapshotBase) GetSnapshotVersion() int { 40 | return a.snapshotVersion 41 | } 42 | -------------------------------------------------------------------------------- /eventsourcing/cold_storage.go: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | type ColdStorage interface { 4 | SaveAll(events []interface{}) 5 | } -------------------------------------------------------------------------------- /eventsourcing/snapshot_metadata.go: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | type SnapshotMetadata struct { 4 | Version int `json:"version"` 5 | } 6 | 7 | func NewSnapshotMetadata(version int) SnapshotMetadata { 8 | return SnapshotMetadata{ 9 | Version: version, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /eventsourcing/type_mapper.go: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type DataToType func(map[string]interface{}) interface{} 9 | type TypeToData func(interface{}) map[string]interface{} 10 | type TypeToDataWithName func(interface{}) (string, map[string]interface{}) 11 | type CreateSnapshot func() interface{} 12 | 13 | type TypeMapper struct { 14 | dataToType map[string]DataToType 15 | typeToData map[reflect.Type]TypeToDataWithName 16 | 17 | snapshotCreation map[string]reflect.Type 18 | snapshotTypeToName map[reflect.Type]string 19 | } 20 | 21 | func NewTypeMapper() *TypeMapper { 22 | return &TypeMapper{ 23 | dataToType: make(map[string]DataToType), 24 | typeToData: make(map[reflect.Type]TypeToDataWithName), 25 | snapshotCreation: make(map[string]reflect.Type), 26 | snapshotTypeToName: make(map[reflect.Type]string), 27 | } 28 | } 29 | 30 | func (tm *TypeMapper) MapEvent(eventType reflect.Type, name string, dt DataToType, td TypeToData) error { 31 | 32 | if name == "" { 33 | return fmt.Errorf("need name for type mapping") 34 | } 35 | 36 | if _, exists := tm.typeToData[eventType]; exists { 37 | return nil 38 | } 39 | 40 | tm.dataToType[name] = dt 41 | tm.typeToData[eventType] = func(t interface{}) (string, map[string]interface{}) { 42 | data := td(t) 43 | return name, data 44 | } 45 | return nil 46 | } 47 | 48 | func (tm *TypeMapper) GetDataToType(typeName string) (DataToType, error) { 49 | if dt, exists := tm.dataToType[typeName]; exists { 50 | return dt, nil 51 | } 52 | return nil, fmt.Errorf("failed to find type mapped with '%s'", typeName) 53 | } 54 | 55 | func (tm *TypeMapper) GetTypeToData(t reflect.Type) (TypeToDataWithName, error) { 56 | if td, exists := tm.typeToData[t]; exists { 57 | return td, nil 58 | } 59 | return nil, fmt.Errorf("failed to find name mapped with '%s'", t) 60 | } 61 | 62 | func (tm *TypeMapper) RegisterType(t reflect.Type, typeName string, c CreateSnapshot) error { 63 | if typeName == "" { 64 | return fmt.Errorf("need type name for registration") 65 | } 66 | 67 | if _, exists := tm.snapshotTypeToName[t]; exists { 68 | return nil 69 | } 70 | 71 | tm.snapshotTypeToName[t] = typeName 72 | tm.snapshotCreation[typeName] = t 73 | return nil 74 | } 75 | 76 | func (tm *TypeMapper) GetTypeName(v interface{}) (string, error) { 77 | t := getValueType(v) 78 | if name, exists := tm.snapshotTypeToName[t]; exists { 79 | return name, nil 80 | } 81 | 82 | return "", fmt.Errorf("type '%v' not registered", t) 83 | } 84 | 85 | func (tm *TypeMapper) GetType(typeName string) (reflect.Type, error) { 86 | if typeName == "" { 87 | return nil, fmt.Errorf("need type name for type creation") 88 | } 89 | 90 | if createSnapshot, exists := tm.snapshotCreation[typeName]; exists { 91 | return createSnapshot, nil 92 | } 93 | 94 | return nil, fmt.Errorf("type '%s' not registered", typeName) 95 | } 96 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/EventStore/training-introduction-go 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/EventStore/EventStore-Client-Go v1.0.3-0.20220302114348-d5a274683572 // indirect 7 | github.com/gofrs/uuid v4.2.0+incompatible // indirect 8 | github.com/google/uuid v1.3.0 // indirect 9 | github.com/labstack/echo/v4 v4.6.3 // indirect 10 | github.com/stretchr/testify v1.7.0 11 | go.mongodb.org/mongo-driver v1.8.4 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= 2 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/EventStore/EventStore-Client-Go v1.0.3-0.20220302114348-d5a274683572 h1:huIqIpSL7DPzYw6INNakEhnHEfJZcNPf6n63mTw+S9g= 6 | github.com/EventStore/EventStore-Client-Go v1.0.3-0.20220302114348-d5a274683572/go.mod h1:JtdlcFRrb1/oUI72s8Y/64HAejLhs7s8UrlrVAY9w20= 7 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 8 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= 9 | github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= 10 | github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= 11 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 12 | github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= 13 | github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= 14 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 15 | github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 16 | github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= 17 | github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= 18 | github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo= 19 | github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 20 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 21 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 22 | github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= 23 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 28 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 29 | github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 30 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 31 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 32 | github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= 33 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 34 | github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= 35 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 36 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 37 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 38 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 39 | github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= 40 | github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 41 | github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= 42 | github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 43 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 44 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 45 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 46 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 47 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 48 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 49 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 50 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 51 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 52 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 53 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 54 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 55 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 56 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 57 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 58 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 59 | github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= 60 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 61 | github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= 62 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 63 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 64 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 65 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 66 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 67 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 68 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 69 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 70 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 71 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 72 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 73 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 74 | github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4= 75 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 76 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 77 | github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= 78 | github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 79 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 80 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 81 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 82 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 83 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 84 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 85 | github.com/labstack/echo/v4 v4.6.3 h1:VhPuIZYxsbPmo4m9KAkMU/el2442eB7EBFFhNTTT9ac= 86 | github.com/labstack/echo/v4 v4.6.3/go.mod h1:Hk5OiHj0kDqmFq7aHe7eDqI7CUhuCrfpupQtLGGLm7A= 87 | github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= 88 | github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= 89 | github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 90 | github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 91 | github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= 92 | github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 93 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 94 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 95 | github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= 96 | github.com/moby/term v0.0.0-20200915141129-7f0af18e79f2/go.mod h1:TjQg8pa4iejrUrjiz0MCtMV38jdMNW4doKSiBrEvCQQ= 97 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= 98 | github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= 99 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 100 | github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 101 | github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 102 | github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= 103 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 104 | github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 105 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 106 | github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= 107 | github.com/opencontainers/runc v1.0.3/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0= 108 | github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= 109 | github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8= 110 | github.com/ory/dockertest/v3 v3.6.3/go.mod h1:EFLcVUOl8qCwp9NyDAcCDtq/QviLtYswW/VbWzUnTNE= 111 | github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 112 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 113 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 114 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 115 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 116 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 117 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 118 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 119 | github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= 120 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 121 | github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 122 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 123 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 124 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 125 | github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 126 | github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 127 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 128 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 129 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 130 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 131 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 132 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 133 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 134 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 135 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 136 | github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= 137 | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 138 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 139 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 140 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 141 | github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= 142 | github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 143 | github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= 144 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= 145 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 146 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 147 | github.com/xdg-go/scram v1.0.2 h1:akYIkZ28e6A96dkWNJQu3nmCzH3YfwMPQExUYDaRv7w= 148 | github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= 149 | github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= 150 | github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= 151 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= 152 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= 153 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 154 | go.mongodb.org/mongo-driver v1.8.4 h1:NruvZPPL0PBcRJKmbswoWSrmHeUvzdxA3GCPfD/NEOA= 155 | go.mongodb.org/mongo-driver v1.8.4/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY= 156 | golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 157 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 158 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 159 | golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 160 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= 161 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 162 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 163 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 164 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 165 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 166 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 167 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 168 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 169 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 170 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 171 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 172 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 173 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 174 | golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 175 | golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 176 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 177 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 178 | golang.org/x/net v0.0.0-20210913180222-943fd674d43e h1:+b/22bPvDYt4NPDcy4xAGCmON713ONAWFeY3Z7I3tR8= 179 | golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 180 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 181 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 182 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 184 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 185 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 186 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 187 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 188 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 189 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 190 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 191 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 192 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 193 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 194 | golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 195 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 196 | golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 197 | golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 198 | golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 199 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 200 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 201 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 202 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 203 | golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 204 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 205 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 206 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 207 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 208 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4= 209 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 210 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 211 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 212 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 213 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 214 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 215 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 216 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 217 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 218 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= 219 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 220 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 221 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 222 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 223 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 224 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 225 | golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 226 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 227 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 228 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 229 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 230 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 231 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 232 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 233 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 234 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 235 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 236 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 237 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 238 | google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70 h1:wboULUXGF3c5qdUnKp+6gLAccE6PRpa/czkYvQ4UXv8= 239 | google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 240 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 241 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 242 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 243 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 244 | google.golang.org/grpc v1.35.0 h1:TwIQcH3es+MojMVojxxfQ3l3OF2KzlRxML2xZq0kRo8= 245 | google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= 246 | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.2.0/go.mod h1:DNq5QpG7LJqD2AamLZ7zvKE0DEpVl2BSEVjFycAAjRY= 247 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 248 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 249 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 250 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 251 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 252 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 253 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 254 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 255 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 256 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 257 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 258 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 259 | google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= 260 | google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 261 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= 262 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 263 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 264 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 265 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 266 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= 267 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 268 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 269 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 270 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 271 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 272 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 273 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 274 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 275 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 276 | gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= 277 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 278 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 279 | -------------------------------------------------------------------------------- /infrastructure/aggregate_store.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/EventStore/training-introduction-go/eventsourcing" 7 | ) 8 | 9 | type AggregateStore interface { 10 | Save(a eventsourcing.AggregateRoot, m CommandMetadata) error 11 | Load(id string, a eventsourcing.AggregateRoot) error 12 | } 13 | 14 | type AggregateNotFoundError struct{} 15 | 16 | func (e AggregateNotFoundError) Error() string { 17 | return fmt.Sprintf("aggregate not found error") 18 | } 19 | -------------------------------------------------------------------------------- /infrastructure/aggregate_tests.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type AggregateTests struct { 11 | dispatcher Dispatcher 12 | store *FakeAggregateStore 13 | latestError error 14 | } 15 | 16 | func NewAggregateTests(store *FakeAggregateStore) AggregateTests { 17 | return AggregateTests{ 18 | store: store, 19 | } 20 | } 21 | 22 | func (t *AggregateTests) RegisterHandlers(handlers CommandHandler) { 23 | commandHandlerMap := NewCommandHandlerMap(handlers) 24 | t.dispatcher = NewDispatcher(commandHandlerMap) 25 | } 26 | 27 | func (t *AggregateTests) Given(events... interface{}) { 28 | t.store.SetInitialEvents(events) 29 | } 30 | 31 | func (t *AggregateTests) When(command interface{}) { 32 | t.latestError = t.dispatcher.Dispatch(command, NewCommandMetadata(uuid.New(), uuid.New())) 33 | } 34 | 35 | func (t *AggregateTests) Then(then func([]interface{}, error)) { 36 | then(t.store.GetStoredChanges(), t.latestError) 37 | } 38 | 39 | func (t *AggregateTests) ThenExpectError(tt *testing.T, expected error) { 40 | assert.Error(tt, t.latestError) 41 | assert.Equal(tt, expected, t.latestError) 42 | } 43 | 44 | func (t *AggregateTests) ThenExpectSingleChange(tt *testing.T, expected interface{}) { 45 | t.ThenExpectChange(tt, 0, expected) 46 | } 47 | 48 | func (t *AggregateTests) ThenExpectChanges(tt *testing.T, expectedChanges []interface{}) { 49 | for i, expected := range expectedChanges { 50 | t.ThenExpectChange(tt, i, expected) 51 | } 52 | } 53 | 54 | func (t *AggregateTests) ThenExpectChange(tt *testing.T, idx int, expected interface{}) { 55 | changes := t.store.GetStoredChanges() 56 | assert.NoError(tt, t.latestError) 57 | assert.IsType(tt, expected, changes[idx]) 58 | assert.Equal(tt, expected, changes[idx]) 59 | } 60 | -------------------------------------------------------------------------------- /infrastructure/command_dispatcher.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Dispatcher struct { 8 | commandHandlerMap CommandHandlerMap 9 | } 10 | 11 | func (d Dispatcher) Dispatch(command interface{}, metadata CommandMetadata) error { 12 | handler, err := d.commandHandlerMap.Get(GetValueType(command)) 13 | if err != nil { 14 | return fmt.Errorf("no handler registered") 15 | } 16 | 17 | return handler(command, metadata) 18 | } 19 | 20 | func NewDispatcher(commandHandlerMap CommandHandlerMap) Dispatcher { 21 | return Dispatcher{ 22 | commandHandlerMap: commandHandlerMap, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /infrastructure/command_handler.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | type Command interface{} 8 | 9 | type CommandHandler interface { 10 | GetHandlers() map[reflect.Type]func(Command, CommandMetadata) error 11 | } 12 | 13 | type CommandHandle interface { 14 | Handle(command Command) error 15 | } 16 | 17 | type CommandHandlerBase struct { 18 | CommandHandler 19 | 20 | handlers map[reflect.Type]func(Command, CommandMetadata) error 21 | } 22 | 23 | func NewCommandHandler() *CommandHandlerBase { 24 | commandHandler := &CommandHandlerBase{} 25 | commandHandler.handlers = make(map[reflect.Type]func(Command, CommandMetadata) error, 0) 26 | return commandHandler 27 | } 28 | 29 | func (c *CommandHandlerBase) GetHandlers() map[reflect.Type]func(Command, CommandMetadata) error { 30 | return c.handlers 31 | } 32 | 33 | func (c *CommandHandlerBase) Register(command interface{}, f func(Command, CommandMetadata) error) { 34 | c.handlers[GetValueType(command)] = f 35 | } 36 | -------------------------------------------------------------------------------- /infrastructure/command_handler_map.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type CommandHandlerMap struct { 9 | handlers map[reflect.Type]func(Command, CommandMetadata) error 10 | } 11 | 12 | func NewCommandHandlerMap(commandHandlers ...CommandHandler) CommandHandlerMap { 13 | c := CommandHandlerMap{} 14 | c.handlers = make(map[reflect.Type]func(Command, CommandMetadata) error, 0) 15 | 16 | for _, ch := range commandHandlers { 17 | for k, h := range ch.GetHandlers() { 18 | c.handlers[k] = h 19 | } 20 | } 21 | 22 | return c 23 | } 24 | 25 | func (c *CommandHandlerMap) Get(t reflect.Type) (func(Command, CommandMetadata) error, error) { 26 | if handler, exists := c.handlers[t]; exists { 27 | return handler, nil 28 | } 29 | return nil, fmt.Errorf("handler not found!!!") 30 | } 31 | -------------------------------------------------------------------------------- /infrastructure/command_metadata.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import "github.com/google/uuid" 4 | 5 | type CorrelationId struct { 6 | Value uuid.UUID 7 | } 8 | 9 | type CausationId struct { 10 | Value uuid.UUID 11 | } 12 | 13 | type CommandMetadata struct { 14 | CorrelationId CorrelationId 15 | CausationId CausationId 16 | } 17 | 18 | func NewCommandMetadata(correlationId, causationId uuid.UUID) CommandMetadata { 19 | return CommandMetadata{ 20 | CorrelationId: CorrelationId{Value: correlationId}, 21 | CausationId: CausationId{Value: causationId}, 22 | } 23 | } 24 | 25 | func NewCommandMetadataFrom(m EventMetadata) CommandMetadata { 26 | return CommandMetadata{ 27 | CorrelationId: m.CorrelationId, 28 | CausationId: m.CausationId, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /infrastructure/es_aggregate_store.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/eventsourcing" 5 | ) 6 | 7 | type EsAggregateStore struct { 8 | AggregateStore 9 | 10 | store EventStore 11 | snapshotThreshold int 12 | } 13 | 14 | func NewEsAggregateStore(store EventStore, snapshotThreshold int) *EsAggregateStore { 15 | return &EsAggregateStore{ 16 | store: store, 17 | snapshotThreshold: snapshotThreshold, 18 | } 19 | } 20 | 21 | func (s *EsAggregateStore) Save(a eventsourcing.AggregateRoot, m CommandMetadata) error { 22 | changes := a.GetChanges() 23 | streamName := eventsourcing.GetStreamName(a) 24 | err := s.store.AppendEvents(streamName, a.GetVersion(), m, changes...) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | if sa, ok := a.(eventsourcing.AggregateRootSnapshot); ok { 30 | newVersion := a.GetVersion() + len(changes) 31 | if (newVersion+1)-sa.GetSnapshotVersion() >= s.snapshotThreshold { 32 | err = s.store.AppendSnapshot(streamName, newVersion, sa.GetSnapshot()) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | } 38 | 39 | a.ClearChanges() 40 | return nil 41 | } 42 | 43 | func (s *EsAggregateStore) Load(aggregateId string, a eventsourcing.AggregateRoot) error { 44 | version := -1 45 | streamName := eventsourcing.GetStreamNameWithId(a, aggregateId) 46 | 47 | if sa, ok := a.(eventsourcing.AggregateRootSnapshot); ok { 48 | sn, md, _ := s.store.LoadSnapshot(streamName) 49 | if sn != nil && md != nil { 50 | sa.LoadSnapshot(sn, md.Version) 51 | version = md.Version + 1 52 | } 53 | } 54 | 55 | events, err := s.store.LoadEvents(streamName, version) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | a.Load(events) 61 | a.ClearChanges() 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /infrastructure/es_checkpoint_store.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/EventStore/EventStore-Client-Go/esdb" 8 | "github.com/gofrs/uuid" 9 | ) 10 | 11 | const ( 12 | CheckpointStreamPrefix = "checkpoint-" 13 | ) 14 | 15 | type EsCheckpointStore struct { 16 | esdb *esdb.Client 17 | serde *EsEventSerde 18 | streamName string 19 | } 20 | 21 | func NewEsCheckpointStore(esdb *esdb.Client, subscriptionName string, serde *EsEventSerde) *EsCheckpointStore { 22 | return &EsCheckpointStore{ 23 | esdb: esdb, 24 | serde: serde, 25 | streamName: CheckpointStreamPrefix + subscriptionName, 26 | } 27 | } 28 | 29 | func (s *EsCheckpointStore) GetCheckpoint() (*uint64, error) { 30 | options := esdb.ReadStreamOptions{ 31 | From: esdb.End{}, 32 | Direction: esdb.Backwards, 33 | } 34 | result, err := s.esdb.ReadStream(context.TODO(), s.streamName, options, 1) 35 | if err != nil { 36 | if esdbError, ok := esdb.FromError(err); !ok { 37 | if esdbError.Code() == esdb.ErrorResourceNotFound { 38 | return nil, &CheckpointNotFoundError{} 39 | } 40 | } 41 | return nil, err 42 | } 43 | 44 | e, err := result.Recv() 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if e.Event != nil { 50 | c := Checkpoint{} 51 | err = json.Unmarshal(e.Event.Data, &c) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return &c.Position, nil 57 | } 58 | 59 | return nil, &CheckpointNotFoundError{} 60 | } 61 | 62 | func (s *EsCheckpointStore) StoreCheckpoint(position uint64) error { 63 | id, err := uuid.NewV4() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | checkpointData, err := json.Marshal(Checkpoint{Position: position}) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | eventData := esdb.EventData{ 74 | EventID: id, 75 | ContentType: esdb.JsonContentType, 76 | EventType: "$checkpoint", 77 | Data: checkpointData, 78 | } 79 | 80 | options := esdb.AppendToStreamOptions{ 81 | ExpectedRevision: esdb.Any{}, 82 | } 83 | 84 | _, err = s.esdb.AppendToStream(context.TODO(), s.streamName, options, eventData) 85 | return err 86 | } 87 | 88 | type Checkpoint struct { 89 | Position uint64 `json:"position"` 90 | } 91 | 92 | type CheckpointNotFoundError struct{} 93 | 94 | func (c *CheckpointNotFoundError) Error() string { 95 | return "checkpoint not found" 96 | } 97 | -------------------------------------------------------------------------------- /infrastructure/es_command_store.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/EventStore/EventStore-Client-Go/esdb" 7 | ) 8 | 9 | const ( 10 | CommandsStreamName = "async_command_handler-day" 11 | ) 12 | 13 | type CommandStore interface { 14 | Send(command interface{}, m CommandMetadata) error 15 | Start() error 16 | } 17 | 18 | type EsCommandStore struct { 19 | eventStore EventStore 20 | esdb *esdb.Client 21 | serde *EsEventSerde 22 | dispatcher *Dispatcher 23 | } 24 | 25 | func NewEsCommandStore(s EventStore, c *esdb.Client, e *EsEventSerde, d *Dispatcher) *EsCommandStore { 26 | return &EsCommandStore{ 27 | eventStore: s, 28 | esdb: c, 29 | serde: e, 30 | dispatcher: d, 31 | } 32 | } 33 | 34 | func (c *EsCommandStore) Send(command interface{}, m CommandMetadata) error { 35 | return c.eventStore.AppendCommand(CommandsStreamName, command, m) 36 | } 37 | 38 | func (c *EsCommandStore) Start() error { 39 | options := esdb.SubscribeToStreamOptions{ 40 | From: esdb.End{}, 41 | } 42 | sub, err := c.esdb.SubscribeToStream(context.TODO(), c.eventStore.GetFullStreamName(CommandsStreamName), options) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | go func() { 48 | for { 49 | s := sub.Recv() 50 | if s.EventAppeared != nil { 51 | cmd, m, err := c.serde.DeserializeCommand(s.EventAppeared) 52 | if err != nil { 53 | if cmd != nil { 54 | panic(err) 55 | } else { 56 | // ignore unknown event type 57 | continue 58 | } 59 | } 60 | 61 | c.dispatcher.Dispatch(cmd, m) 62 | } 63 | 64 | if s.SubscriptionDropped != nil { 65 | panic(s.SubscriptionDropped.Error) 66 | } 67 | } 68 | }() 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /infrastructure/es_event_serde.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | 7 | "github.com/EventStore/EventStore-Client-Go/esdb" 8 | "github.com/EventStore/training-introduction-go/eventsourcing" 9 | "github.com/gofrs/uuid" 10 | ) 11 | 12 | type EsEventSerde struct { 13 | typeMapper *eventsourcing.TypeMapper 14 | } 15 | 16 | func NewEsEventSerde(tm *eventsourcing.TypeMapper) *EsEventSerde { 17 | return &EsEventSerde{ 18 | typeMapper: tm, 19 | } 20 | } 21 | 22 | func (s *EsEventSerde) Serialize(event interface{}, m EventMetadata) (esdb.EventData, error) { 23 | typeToData, err := s.typeMapper.GetTypeToData(GetValueType(event)) 24 | if err != nil { 25 | return esdb.EventData{}, err 26 | } 27 | 28 | id, err := uuid.NewV4() 29 | if err != nil { 30 | return esdb.EventData{}, err 31 | } 32 | 33 | name, jsonData := typeToData(event) 34 | dataBytes, err := json.Marshal(jsonData) 35 | if err != nil { 36 | return esdb.EventData{}, err 37 | } 38 | 39 | metadataBytes, err := json.Marshal(m) 40 | if err != nil { 41 | return esdb.EventData{}, err 42 | } 43 | 44 | return esdb.EventData{ 45 | EventID: id, 46 | ContentType: esdb.JsonContentType, 47 | EventType: name, 48 | Data: dataBytes, 49 | Metadata: metadataBytes, 50 | }, nil 51 | } 52 | 53 | func (s *EsEventSerde) Deserialize(r *esdb.ResolvedEvent) (interface{}, *EventMetadata, error) { 54 | dataToType, err := s.typeMapper.GetDataToType(r.Event.EventType) 55 | if err != nil { 56 | return nil, nil, err 57 | } 58 | 59 | m := map[string]interface{}{} 60 | err = json.Unmarshal(r.Event.Data, &m) 61 | if err != nil { 62 | return nil, nil, err 63 | } 64 | 65 | metadata := EventMetadata{} 66 | err = json.Unmarshal(r.Event.UserMetadata, &metadata) 67 | if err != nil { 68 | return nil, nil, err 69 | } 70 | 71 | return dataToType(m), &metadata, nil 72 | } 73 | 74 | func (s *EsEventSerde) SerializeSnapshot(snapshot interface{}, m eventsourcing.SnapshotMetadata) (esdb.EventData, error) { 75 | id, err := uuid.NewV4() 76 | if err != nil { 77 | return esdb.EventData{}, err 78 | } 79 | 80 | name, err := s.typeMapper.GetTypeName(snapshot) 81 | if err != nil { 82 | return esdb.EventData{}, err 83 | } 84 | 85 | snapshotBytes, err := json.Marshal(snapshot) 86 | if err != nil { 87 | return esdb.EventData{}, err 88 | } 89 | 90 | metadataBytes, err := json.Marshal(m) 91 | if err != nil { 92 | return esdb.EventData{}, err 93 | } 94 | 95 | return esdb.EventData{ 96 | EventID: id, 97 | ContentType: esdb.JsonContentType, 98 | EventType: name, 99 | Data: snapshotBytes, 100 | Metadata: metadataBytes, 101 | }, nil 102 | } 103 | 104 | func (s *EsEventSerde) DeserializeSnapshot(r *esdb.ResolvedEvent) (interface{}, *eventsourcing.SnapshotMetadata, error) { 105 | snapshot, err := s.deserializeToType(r.Event) 106 | if err != nil { 107 | return nil, nil, err 108 | } 109 | 110 | metadata := eventsourcing.SnapshotMetadata{} 111 | err = json.Unmarshal(r.Event.UserMetadata, &metadata) 112 | if err != nil { 113 | return nil, nil, err 114 | } 115 | 116 | return snapshot, &metadata, nil 117 | } 118 | 119 | func (s *EsEventSerde) SerializeCommand(command interface{}, m CommandMetadata) (esdb.EventData, error) { 120 | id, err := uuid.NewV4() 121 | if err != nil { 122 | return esdb.EventData{}, err 123 | } 124 | 125 | name, err := s.typeMapper.GetTypeName(command) 126 | if err != nil { 127 | return esdb.EventData{}, err 128 | } 129 | 130 | commandBytes, err := json.Marshal(command) 131 | if err != nil { 132 | return esdb.EventData{}, err 133 | } 134 | 135 | metadataBytes, err := json.Marshal(m) 136 | if err != nil { 137 | return esdb.EventData{}, err 138 | } 139 | 140 | return esdb.EventData{ 141 | EventID: id, 142 | ContentType: esdb.JsonContentType, 143 | EventType: name, 144 | Data: commandBytes, 145 | Metadata: metadataBytes, 146 | }, nil 147 | } 148 | 149 | func (s *EsEventSerde) DeserializeCommand(r *esdb.ResolvedEvent) (interface{}, CommandMetadata, error) { 150 | metadata := CommandMetadata{} 151 | cmd, err := s.deserializeToType(r.Event) 152 | if err != nil { 153 | return nil, metadata, err 154 | } 155 | 156 | err = json.Unmarshal(r.Event.UserMetadata, &metadata) 157 | if err != nil { 158 | return nil, metadata, err 159 | } 160 | 161 | return cmd, metadata, nil 162 | } 163 | 164 | func (s *EsEventSerde) deserializeToType(e *esdb.RecordedEvent) (interface{}, error) { 165 | t, err := s.typeMapper.GetType(e.EventType) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | cmd := reflect.New(t).Interface() 171 | err = json.Unmarshal(e.Data, &cmd) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | return reflect.ValueOf(cmd).Elem().Interface(), nil 177 | } 178 | -------------------------------------------------------------------------------- /infrastructure/es_event_store.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "math" 9 | 10 | "github.com/EventStore/EventStore-Client-Go/esdb" 11 | "github.com/EventStore/training-introduction-go/eventsourcing" 12 | ) 13 | 14 | //todo move to /event-sourcing 15 | 16 | type CommandEnvelope struct { 17 | Command interface{} 18 | Metadata CommandMetadata 19 | } 20 | 21 | func NewCommandEnvelope(command interface{}, m CommandMetadata) CommandEnvelope { 22 | return CommandEnvelope{ 23 | Command: command, 24 | Metadata: m, 25 | } 26 | } 27 | 28 | type EventStore interface { 29 | GetFullStreamName(streamName string) string 30 | GetLastVersion(streamName string) (uint64, error) 31 | TruncateStream(streamName string, version uint64) error 32 | 33 | AppendEvents(streamName string, version int, m CommandMetadata, events ...interface{}) error 34 | AppendEventsToAny(streamName string, m CommandMetadata, events ...interface{}) error 35 | LoadEvents(streamName string, version int) ([]interface{}, error) 36 | LoadEventsFromStart(streamName string) ([]interface{}, error) 37 | 38 | AppendSnapshot(streamName string, version int, snapshot interface{}) error 39 | LoadSnapshot(streamName string) (interface{}, *eventsourcing.SnapshotMetadata, error) 40 | 41 | AppendCommand(streamName string, command interface{}, m CommandMetadata) error 42 | LoadCommand(streamName string) ([]CommandEnvelope, error) 43 | } 44 | 45 | type EsEventStore struct { 46 | EventStore 47 | 48 | esdb *esdb.Client 49 | tenantPrefix string 50 | esSerde *EsEventSerde 51 | } 52 | 53 | func NewEsEventStore(esdb *esdb.Client, tenantPrefix string, serder *EsEventSerde) *EsEventStore { 54 | return &EsEventStore{ 55 | esdb: esdb, 56 | tenantPrefix: tenantPrefix, 57 | esSerde: serder, 58 | } 59 | } 60 | 61 | func (s *EsEventStore) GetFullStreamName(streamName string) string { 62 | return s.tenantPrefix + streamName 63 | } 64 | 65 | func (s *EsEventStore) GetLastVersion(streamName string) (uint64, error) { 66 | options := esdb.ReadStreamOptions{ 67 | From: esdb.End{}, 68 | Direction: esdb.Backwards, 69 | } 70 | result, err := s.esdb.ReadStream(context.TODO(), s.GetFullStreamName(streamName), options, 1) 71 | if err != nil { 72 | return 0, err 73 | } 74 | 75 | e, err := result.Recv() 76 | if err != nil { 77 | return 0, err 78 | } 79 | 80 | if e.Event != nil { 81 | return e.Event.EventNumber, nil 82 | } 83 | 84 | return 0, fmt.Errorf("failed to retrieve last version") 85 | } 86 | 87 | func (s *EsEventStore) AppendEventsToAny(streamName string, m CommandMetadata, events ...interface{}) error { 88 | options := esdb.AppendToStreamOptions{ 89 | ExpectedRevision: esdb.Any{}, 90 | } 91 | return s.appendEvents(streamName, options, m, events...) 92 | } 93 | 94 | func (s *EsEventStore) AppendEvents(streamName string, version int, m CommandMetadata, events ...interface{}) error { 95 | options := esdb.AppendToStreamOptions{} 96 | if version == -1 { 97 | options.ExpectedRevision = esdb.NoStream{} 98 | } else { 99 | options.ExpectedRevision = esdb.Revision(uint64(version)) 100 | } 101 | 102 | return s.appendEvents(streamName, options, m, events...) 103 | } 104 | 105 | func (s *EsEventStore) appendEvents(streamName string, o esdb.AppendToStreamOptions, m CommandMetadata, events ...interface{}) error { 106 | if events == nil || len(events) == 0 { 107 | return nil 108 | } 109 | 110 | var eventData []esdb.EventData 111 | for _, e := range events { 112 | ed, err := s.esSerde.Serialize(e, NewEventMetadataFrom(m)) 113 | if err != nil { 114 | return err 115 | } 116 | eventData = append(eventData, ed) 117 | } 118 | 119 | _, err := s.esdb.AppendToStream(context.TODO(), s.GetFullStreamName(streamName), o, eventData...) 120 | return err 121 | } 122 | 123 | func (s *EsEventStore) LoadEventsFromStart(streamName string) ([]interface{}, error) { 124 | options := esdb.ReadStreamOptions{ 125 | From: esdb.Start{}, 126 | Direction: esdb.Forwards, 127 | } 128 | 129 | return s.loadEvents(streamName, options) 130 | } 131 | 132 | func (s *EsEventStore) LoadEvents(streamName string, version int) ([]interface{}, error) { 133 | options := esdb.ReadStreamOptions{ 134 | Direction: esdb.Forwards, 135 | } 136 | 137 | if version == -1 { 138 | options.From = esdb.Start{} 139 | } else { 140 | options.From = esdb.Revision(uint64(version)) 141 | } 142 | 143 | return s.loadEvents(streamName, options) 144 | } 145 | 146 | func (s *EsEventStore) loadEvents(streamName string, o esdb.ReadStreamOptions) ([]interface{}, error) { 147 | events := make([]interface{}, 0) 148 | stream, err := s.esdb.ReadStream(context.TODO(), s.GetFullStreamName(streamName), o, math.MaxInt64) 149 | if err != nil { 150 | if esdbError, ok := esdb.FromError(err); !ok { 151 | if esdbError.Code() == esdb.ErrorResourceNotFound { 152 | return events, nil 153 | } else if errors.Is(err, io.EOF) { 154 | return events, nil 155 | } 156 | } 157 | return nil, err 158 | } 159 | 160 | for { 161 | event, err := stream.Recv() 162 | if errors.Is(err, io.EOF) { 163 | break 164 | } 165 | 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | e, _, err := s.esSerde.Deserialize(event) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | events = append(events, e) 176 | } 177 | 178 | return events, nil 179 | } 180 | 181 | func (s *EsEventStore) AppendSnapshot(streamName string, version int, snapshot interface{}) error { 182 | eventData, err := s.esSerde.SerializeSnapshot(snapshot, eventsourcing.NewSnapshotMetadata(version)) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | options := esdb.AppendToStreamOptions{ExpectedRevision: esdb.Any{}} 188 | snapshotName := s.GetFullStreamName("snapshot-" + streamName) 189 | _, err = s.esdb.AppendToStream(context.Background(), snapshotName, options, eventData) 190 | return err 191 | } 192 | 193 | func (s *EsEventStore) LoadSnapshot(streamName string) (interface{}, *eventsourcing.SnapshotMetadata, error) { 194 | options := esdb.ReadStreamOptions{ 195 | From: esdb.End{}, 196 | Direction: esdb.Backwards, 197 | } 198 | snapshotName := s.GetFullStreamName("snapshot-" + streamName) 199 | stream, err := s.esdb.ReadStream(context.Background(), snapshotName, options, 1) 200 | if err != nil { 201 | return nil, nil, err 202 | } 203 | 204 | for { 205 | event, err := stream.Recv() 206 | if errors.Is(err, io.EOF) { 207 | return nil, nil, fmt.Errorf("unexpected end of stream") 208 | } 209 | 210 | if err != nil { 211 | return nil, nil, err 212 | } 213 | 214 | return s.esSerde.DeserializeSnapshot(event) 215 | } 216 | 217 | return nil, nil, fmt.Errorf("failed to load snapshot") 218 | } 219 | 220 | func (s *EsEventStore) TruncateStream(streamName string, beforeVersion uint64) error { 221 | options := esdb.AppendToStreamOptions{ExpectedRevision: esdb.Any{}} 222 | metadata := esdb.StreamMetadata{} 223 | metadata.SetTruncateBefore(beforeVersion) 224 | _, err := s.esdb.SetStreamMetadata(context.Background(), s.GetFullStreamName(streamName), options, metadata) 225 | return err 226 | } 227 | 228 | func (s *EsEventStore) AppendCommand(streamName string, command interface{}, m CommandMetadata) error { 229 | eventData, err := s.esSerde.SerializeCommand(command, m) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | options := esdb.AppendToStreamOptions{ExpectedRevision: esdb.Any{}} 235 | _, err = s.esdb.AppendToStream(context.TODO(), s.GetFullStreamName(streamName), options, eventData) 236 | return err 237 | } 238 | 239 | func (s *EsEventStore) LoadCommand(streamName string) ([]CommandEnvelope, error) { 240 | options := esdb.ReadStreamOptions{ 241 | From: esdb.Start{}, 242 | Direction: esdb.Forwards, 243 | } 244 | stream, err := s.esdb.ReadStream(context.TODO(), s.GetFullStreamName(streamName), options, math.MaxUint64) 245 | if err != nil { 246 | return nil, err 247 | } 248 | 249 | cmdEnvelopes := make([]CommandEnvelope, 0) 250 | for { 251 | event, err := stream.Recv() 252 | if errors.Is(err, io.EOF) { 253 | break 254 | } 255 | 256 | if err != nil { 257 | return nil, err 258 | } 259 | 260 | cmd, metadata, err := s.esSerde.DeserializeCommand(event) 261 | if err != nil { 262 | return nil, err 263 | } 264 | 265 | cmdEnvelopes = append(cmdEnvelopes, NewCommandEnvelope(cmd, metadata)) 266 | } 267 | 268 | return cmdEnvelopes, nil 269 | } 270 | -------------------------------------------------------------------------------- /infrastructure/event_handler.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | type EventHandler interface { 8 | CanHandle(reflect.Type) bool 9 | Handle(reflect.Type, interface{}, EventMetadata) error 10 | GetHandledTypes() []reflect.Type 11 | } 12 | 13 | type EventHandlerBase struct { 14 | EventHandler 15 | 16 | handlers []EventHandlerEnvelope 17 | handledTypes map[reflect.Type]bool 18 | types []reflect.Type 19 | } 20 | 21 | func NewEventHandler() EventHandlerBase { 22 | return EventHandlerBase{ 23 | handledTypes: make(map[reflect.Type]bool, 0), 24 | } 25 | } 26 | 27 | func (p *EventHandlerBase) When(event interface{}, handler func(interface{}, EventMetadata)error) { 28 | t := GetValueType(event) 29 | p.handlers = append(p.handlers, NewEventHandlerEnvelope(t, handler)) 30 | p.handledTypes[t] = true 31 | } 32 | 33 | func (p *EventHandlerBase) CanHandle(t reflect.Type) bool { 34 | _, exists := p.handledTypes[t] 35 | return exists 36 | } 37 | 38 | func (p *EventHandlerBase) Handle(t reflect.Type, event interface{}, m EventMetadata) error { 39 | for _, h := range p.handlers { 40 | if h.Type == t { 41 | err := h.Handler(event, m) 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | type EventHandlerEnvelope struct { 51 | Type reflect.Type 52 | Handler func(interface{}, EventMetadata)error 53 | } 54 | 55 | func NewEventHandlerEnvelope(t reflect.Type, handler func(interface{}, EventMetadata)error) EventHandlerEnvelope { 56 | return EventHandlerEnvelope{ 57 | Type: t, 58 | Handler: handler, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /infrastructure/event_metadata.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import "github.com/google/uuid" 4 | 5 | type EventMetadata struct { 6 | CorrelationId CorrelationId 7 | CausationId CausationId 8 | Position int 9 | } 10 | 11 | func NewEventMetadata(correlationId, causationId uuid.UUID, position int) EventMetadata { 12 | return EventMetadata{ 13 | CorrelationId: CorrelationId{Value: correlationId}, 14 | CausationId: CausationId{Value: causationId}, 15 | Position: position, 16 | } 17 | } 18 | 19 | func NewEventMetadataFrom(m CommandMetadata) EventMetadata { 20 | return EventMetadata{ 21 | CorrelationId: m.CorrelationId, 22 | CausationId: m.CausationId, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /infrastructure/fake_aggregate_store.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/eventsourcing" 5 | ) 6 | 7 | type FakeAggregateStore struct { 8 | AggregateStore 9 | 10 | aggregate eventsourcing.AggregateRoot 11 | initialEvents []interface{} 12 | } 13 | 14 | func NewFakeAggregateStore() *FakeAggregateStore { 15 | return &FakeAggregateStore{} 16 | } 17 | 18 | func (f *FakeAggregateStore) SetInitialEvents(events []interface{}) { 19 | f.initialEvents = events 20 | } 21 | 22 | func (f *FakeAggregateStore) GetStoredChanges() []interface{} { 23 | if f.aggregate != nil { 24 | return f.aggregate.GetChanges() 25 | } 26 | 27 | return []interface{}{} 28 | } 29 | 30 | func (f *FakeAggregateStore) Save(a eventsourcing.AggregateRoot, m CommandMetadata) error { 31 | f.aggregate = a 32 | return nil 33 | } 34 | 35 | func (f *FakeAggregateStore) Load(aggregateId string, aggregate eventsourcing.AggregateRoot) error { 36 | aggregate.Load(f.initialEvents) 37 | aggregate.ClearChanges() 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /infrastructure/handler_tests.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type HandlerTests struct { 11 | T *testing.T 12 | 13 | latestError error 14 | handlerFactory func() EventHandler 15 | 16 | EnableAtLeastOnceMonkey bool 17 | EnableAtLeastOnceGorilla bool 18 | } 19 | 20 | func NewHandlerTests(t *testing.T) HandlerTests { 21 | return HandlerTests{T: t} 22 | } 23 | 24 | func (p *HandlerTests) SetHandlerFactory(f func() EventHandler) { 25 | p.handlerFactory = f 26 | } 27 | 28 | func (p *HandlerTests) Given(events ...interface{}) { 29 | assert.NotNil(p.T, p.handlerFactory) 30 | 31 | correlationId := uuid.New() 32 | causationId := uuid.New() 33 | 34 | eventHandler := p.handlerFactory() 35 | for i, e := range events { 36 | m := NewEventMetadata(correlationId, causationId, i) 37 | 38 | err := eventHandler.Handle(GetValueType(e), e, m) 39 | if err != nil { 40 | p.latestError = err 41 | return 42 | } 43 | 44 | if p.EnableAtLeastOnceMonkey { 45 | err = eventHandler.Handle(GetValueType(e), e, m) 46 | if err != nil { 47 | p.latestError = err 48 | return 49 | } 50 | } 51 | } 52 | 53 | if p.EnableAtLeastOnceGorilla { 54 | for _, e := range events[:len(events)-1] { 55 | 56 | m := NewEventMetadata(correlationId, causationId, 7) 57 | 58 | err := eventHandler.Handle(GetValueType(e), e, m) 59 | if err != nil { 60 | p.latestError = err 61 | return 62 | } 63 | 64 | if p.EnableAtLeastOnceMonkey { 65 | err = eventHandler.Handle(GetValueType(e), e, m) 66 | if err != nil { 67 | p.latestError = err 68 | return 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | func (p *HandlerTests) Then(expected, actual interface{}) { 76 | assert.NoError(p.T, p.latestError) 77 | assert.Equal(p.T, expected, actual) 78 | } 79 | -------------------------------------------------------------------------------- /infrastructure/infrastructur.go: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import "reflect" 4 | 5 | func GetValueType(t interface{}) reflect.Type { 6 | v := reflect.ValueOf(t) 7 | if v.Kind() == reflect.Ptr { 8 | v = v.Elem() 9 | } 10 | return v.Type() 11 | } 12 | -------------------------------------------------------------------------------- /infrastructure/inmemory/archivable_days_repository.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/EventStore/training-introduction-go/domain/readmodel" 7 | ) 8 | 9 | type ArchivableDaysRepository struct { 10 | readmodel.ArchivableDaysRepository 11 | 12 | archivableDays []readmodel.ArchivableDay 13 | } 14 | 15 | func NewArchivableDaysRepository() *ArchivableDaysRepository { 16 | return &ArchivableDaysRepository{} 17 | } 18 | 19 | func (r *ArchivableDaysRepository) Add(d readmodel.ArchivableDay) error { 20 | r.archivableDays = append(r.archivableDays, d) 21 | return nil 22 | } 23 | 24 | func (r *ArchivableDaysRepository) FindAll(dateThreshold time.Time) ([]readmodel.ArchivableDay, error) { 25 | days := make([]readmodel.ArchivableDay, 0) 26 | for _, day := range r.archivableDays { 27 | if day.Date.Before(dateThreshold) || day.Date.Equal(dateThreshold) { 28 | days = append(days, day) 29 | } 30 | } 31 | return days, nil 32 | } 33 | -------------------------------------------------------------------------------- /infrastructure/inmemory/cold_storage.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import "github.com/EventStore/training-introduction-go/eventsourcing" 4 | 5 | type ColdStorage struct { 6 | eventsourcing.ColdStorage 7 | 8 | Events []interface{} 9 | } 10 | 11 | func NewColdStorage() *ColdStorage { 12 | return &ColdStorage{ 13 | Events: make([]interface{}, 0), 14 | } 15 | } 16 | 17 | func (s *ColdStorage) SaveAll(events []interface{}) { 18 | for _, e := range events { 19 | s.Events = append(s.Events, e) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /infrastructure/mongodb/archivable_days_repository.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/EventStore/training-introduction-go/domain/readmodel" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type ArchivableDayRepository struct { 14 | readmodel.ArchivableDaysRepository 15 | 16 | collection *mongo.Collection 17 | } 18 | 19 | func NewArchivableDayRepository(db *mongo.Database) *AvailableSlotsRepository { 20 | return &AvailableSlotsRepository{ 21 | collection: db.Collection("archivable_day"), 22 | } 23 | } 24 | 25 | func (m *AvailableSlotsRepository) Add(s readmodel.ArchivableDay) error { 26 | _, err := m.collection.InsertOne(context.TODO(), s) 27 | return err 28 | } 29 | 30 | func (m *AvailableSlotsRepository) FindAll(dateThreshold time.Time) ([]readmodel.ArchivableDay, error) { 31 | slots := make([]readmodel.ArchivableDay, 0) 32 | cur, err := m.collection.Find(context.TODO(), bson.D{{"date", bson.M{ 33 | "$gte": primitive.NewDateTimeFromTime(dateThreshold), 34 | }}}) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | for cur.Next(context.TODO()) { 40 | var result readmodel.ArchivableDay 41 | err := cur.Decode(&result) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | slots = append(slots, result) 47 | } 48 | 49 | if err := cur.Err(); err != nil { 50 | return nil, err 51 | } 52 | 53 | return slots, nil 54 | } 55 | -------------------------------------------------------------------------------- /infrastructure/mongodb/available_slot.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/EventStore/training-introduction-go/domain/readmodel" 7 | ) 8 | 9 | type AvailableSlot struct { 10 | Id string 11 | DayId string 12 | Date string 13 | StartTime string 14 | Duration time.Duration 15 | IsBooked bool 16 | } 17 | 18 | func (a *AvailableSlot) ToAvailableSlot() readmodel.AvailableSlot { 19 | return readmodel.AvailableSlot{ 20 | Id: a.Id, 21 | DayId: a.DayId, 22 | Date: a.Date, 23 | StartTime: a.StartTime, 24 | Duration: a.Duration, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /infrastructure/mongodb/available_slot_v2.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/EventStore/training-introduction-go/domain/readmodel" 7 | ) 8 | 9 | type AvailableSlotV2 struct { 10 | Id string 11 | DayId string 12 | Date string 13 | StartTime string 14 | Duration time.Duration 15 | IsBooked bool 16 | } 17 | 18 | func (a *AvailableSlotV2) ToAvailableSlot() readmodel.AvailableSlot { 19 | return readmodel.AvailableSlot{ 20 | Id: a.Id, 21 | DayId: a.DayId, 22 | Date: a.Date, 23 | StartTime: a.StartTime, 24 | Duration: a.Duration, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /infrastructure/mongodb/available_slots_repository.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/EventStore/training-introduction-go/domain/readmodel" 9 | "github.com/google/uuid" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/mongo" 12 | ) 13 | 14 | type AvailableSlotsRepository struct { 15 | readmodel.AvailableSlotsRepository 16 | 17 | collection *mongo.Collection 18 | } 19 | 20 | func NewAvailableSlotsRepository(db *mongo.Database) *AvailableSlotsRepository { 21 | return &AvailableSlotsRepository{ 22 | collection: db.Collection("available_slots_v2"), 23 | } 24 | } 25 | 26 | func (m *AvailableSlotsRepository) AddSlot(s AvailableSlot) error { 27 | _, err := m.collection.InsertOne(context.TODO(), s) 28 | return err 29 | } 30 | 31 | func (m *AvailableSlotsRepository) HideSlot(slotId uuid.UUID) error { 32 | result, err := m.collection.UpdateOne( 33 | context.TODO(), 34 | bson.M{"id": slotId.String()}, 35 | bson.D{{"$set", bson.D{{"isbooked", true}}}}) 36 | 37 | if result.ModifiedCount == 0 { 38 | return fmt.Errorf("failed to hide slot") 39 | } 40 | 41 | return err 42 | } 43 | 44 | func (m *AvailableSlotsRepository) ShowSlot(slotId uuid.UUID) error { 45 | result, err := m.collection.UpdateOne( 46 | context.TODO(), 47 | bson.M{"id": slotId.String()}, 48 | bson.D{{"$set", bson.D{{"isbooked", false}}}}) 49 | 50 | if result.ModifiedCount == 0 { 51 | return fmt.Errorf("failed to show slot") 52 | } 53 | 54 | return err 55 | } 56 | 57 | func (m *AvailableSlotsRepository) DeleteSlot(slotId uuid.UUID) error { 58 | result, err := m.collection.DeleteOne( 59 | context.TODO(), 60 | bson.M{"id": slotId.String()}) 61 | 62 | if result.DeletedCount == 0 { 63 | return fmt.Errorf("failed to delete slot") 64 | } 65 | 66 | return err 67 | } 68 | 69 | func (m *AvailableSlotsRepository) GetSlotsAvailableOn(date time.Time) ([]readmodel.AvailableSlot, error) { 70 | slots := make([]readmodel.AvailableSlot, 0) 71 | cur, err := m.collection.Find(context.TODO(), bson.D{{"date", date.Format("02-01-2006")}, {"isbooked", false}}) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | for cur.Next(context.TODO()) { 77 | var result AvailableSlot 78 | err := cur.Decode(&result) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | slots = append(slots, result.ToAvailableSlot()) 84 | } 85 | 86 | if err := cur.Err(); err != nil { 87 | return nil, err 88 | } 89 | 90 | return slots, nil 91 | } 92 | -------------------------------------------------------------------------------- /infrastructure/mongodb/available_slots_repository_v2.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/EventStore/training-introduction-go/domain/readmodel" 9 | "github.com/google/uuid" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/mongo" 12 | ) 13 | 14 | type AvailableSlotsRepositoryV2 struct { 15 | readmodel.AvailableSlotsRepository 16 | 17 | db *mongo.Database 18 | collection *mongo.Collection 19 | } 20 | 21 | func NewAvailableSlotsRepositoryV2(db *mongo.Database) *AvailableSlotsRepositoryV2 { 22 | return &AvailableSlotsRepositoryV2{ 23 | db: db, 24 | collection: db.Collection("available_slots"), 25 | } 26 | } 27 | 28 | func (m *AvailableSlotsRepositoryV2) AddSlot(s AvailableSlotV2) error { 29 | _, err := m.collection.InsertOne(context.TODO(), s) 30 | return err 31 | } 32 | 33 | func (m *AvailableSlotsRepositoryV2) HideSlot(slotId uuid.UUID) error { 34 | result, err := m.collection.UpdateOne( 35 | context.TODO(), 36 | bson.M{"id": slotId.String()}, 37 | bson.D{{"$set", bson.D{{"isbooked", true}}}}) 38 | 39 | if result.ModifiedCount == 0 { 40 | return fmt.Errorf("failed to hide slot") 41 | } 42 | 43 | return err 44 | } 45 | 46 | func (m *AvailableSlotsRepositoryV2) ShowSlot(slotId uuid.UUID) error { 47 | result, err := m.collection.UpdateOne( 48 | context.TODO(), 49 | bson.M{"id": slotId.String()}, 50 | bson.D{{"$set", bson.D{{"isbooked", false}}}}) 51 | 52 | if result.ModifiedCount == 0 { 53 | return fmt.Errorf("failed to show slot") 54 | } 55 | 56 | return err 57 | } 58 | 59 | func (m *AvailableSlotsRepositoryV2) DeleteSlot(slotId uuid.UUID) error { 60 | result, err := m.collection.DeleteOne( 61 | context.TODO(), 62 | bson.M{"id": slotId.String()}) 63 | 64 | if result.DeletedCount == 0 { 65 | return fmt.Errorf("failed to delete slot") 66 | } 67 | 68 | return err 69 | } 70 | 71 | func (m *AvailableSlotsRepositoryV2) GetSlotsAvailableOn(date time.Time) ([]readmodel.AvailableSlot, error) { 72 | slots := make([]readmodel.AvailableSlot, 0) 73 | cur, err := m.collection.Find(context.TODO(), bson.D{{"date", date.Format("02-01-2006")}, {"isbooked", false}}) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | for cur.Next(context.TODO()) { 79 | var result AvailableSlotV2 80 | err := cur.Decode(&result) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | slots = append(slots, result.ToAvailableSlot()) 86 | } 87 | 88 | if err := cur.Err(); err != nil { 89 | return nil, err 90 | } 91 | 92 | return slots, nil 93 | } 94 | -------------------------------------------------------------------------------- /infrastructure/mongodb/booked_slots_repository.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/EventStore/training-introduction-go/domain/readmodel" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | ) 11 | 12 | type BookedSlotsRepository struct { 13 | readmodel.BookedSlotsRepository 14 | 15 | db *mongo.Database 16 | collection *mongo.Collection 17 | } 18 | 19 | func NewBookedSlotsRepository(db *mongo.Database) *BookedSlotsRepository { 20 | return &BookedSlotsRepository{ 21 | db: db, 22 | collection: db.Collection("booked_slots"), 23 | } 24 | } 25 | 26 | func (m *BookedSlotsRepository) AddSlot(s readmodel.BookedSlot) error { 27 | _, err := m.collection.InsertOne(context.TODO(), s) 28 | return err 29 | } 30 | 31 | func (m *BookedSlotsRepository) MarkSlotAsBooked(slotId, patientId string) error { 32 | result, err := m.collection.UpdateOne( 33 | context.TODO(), 34 | bson.M{"slotid": slotId}, 35 | bson.D{{"$set", bson.D{{"isbooked", true}, {"patientid", patientId}}}}) 36 | 37 | if result.UpsertedCount == 0 { 38 | return fmt.Errorf("failed to mark slot as booked") 39 | } 40 | 41 | return err 42 | } 43 | 44 | func (m *BookedSlotsRepository) MarkSlotAsAvailable(slotId string) error { 45 | result, err := m.collection.UpdateOne( 46 | context.TODO(), 47 | bson.M{"slotid": slotId}, 48 | bson.D{{"$set", bson.D{{"isbooked", true}, {"patientid", ""}}}}) 49 | 50 | if result.UpsertedCount == 0 { 51 | return fmt.Errorf("failed to mark slot as available") 52 | } 53 | 54 | return err 55 | } 56 | 57 | func (m *BookedSlotsRepository) GetSlot(slotId string) (readmodel.BookedSlot, error) { 58 | slot := readmodel.BookedSlot{} 59 | result := m.collection.FindOne(context.TODO(), bson.M{"slotid": slotId}) 60 | if result == nil { 61 | return slot, fmt.Errorf("failed to find slot") 62 | } 63 | 64 | err := result.Decode(&slot) 65 | if err != nil { 66 | return slot, err 67 | } 68 | 69 | return slot, nil 70 | } 71 | 72 | func (m *BookedSlotsRepository) CountByPatientAndMonth(patientId string, month int) (int, error) { 73 | result, err := m.collection.CountDocuments(context.TODO(), bson.D{{"patientid", patientId}, {"month", month}}) 74 | if err != nil { 75 | return 0, err 76 | } 77 | 78 | return int(result), nil 79 | } 80 | -------------------------------------------------------------------------------- /infrastructure/projections/projector.go: -------------------------------------------------------------------------------- 1 | package projections 2 | 3 | import ( 4 | "github.com/EventStore/training-introduction-go/infrastructure" 5 | ) 6 | 7 | type Subscription interface { 8 | Project(event interface{}, m infrastructure.EventMetadata) 9 | } 10 | 11 | type Projector struct { 12 | Subscription 13 | 14 | projection infrastructure.EventHandler 15 | } 16 | 17 | func NewProjector(p infrastructure.EventHandler) *Projector { 18 | return &Projector{ 19 | projection: p, 20 | } 21 | } 22 | 23 | func (p Projector) Project(event interface{}, m infrastructure.EventMetadata) { 24 | t := infrastructure.GetValueType(event) 25 | if !p.projection.CanHandle(t) { 26 | return 27 | } 28 | 29 | p.projection.Handle(t, event, m) 30 | } 31 | -------------------------------------------------------------------------------- /infrastructure/projections/subscription_manager.go: -------------------------------------------------------------------------------- 1 | package projections 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/EventStore/EventStore-Client-Go/esdb" 10 | "github.com/EventStore/training-introduction-go/infrastructure" 11 | ) 12 | 13 | type SubscriptionManager struct { 14 | esdb *esdb.Client 15 | checkpointStore *infrastructure.EsCheckpointStore 16 | serde *infrastructure.EsEventSerde 17 | subscriptions []Subscription 18 | streamName string 19 | typesByName map[string]reflect.Type 20 | isAllStream bool 21 | } 22 | 23 | func NewSubscriptionManager(esdb *esdb.Client, c *infrastructure.EsCheckpointStore, s *infrastructure.EsEventSerde, 24 | streamName string, subs ...Subscription) *SubscriptionManager { 25 | return &SubscriptionManager{ 26 | esdb: esdb, 27 | subscriptions: subs, 28 | checkpointStore: c, 29 | serde: s, 30 | streamName: streamName, 31 | isAllStream: streamName == "$all", 32 | } 33 | } 34 | 35 | func (m SubscriptionManager) Start(ctx context.Context) error { 36 | position, err := m.checkpointStore.GetCheckpoint() 37 | if err != nil && !errors.Is(err, &infrastructure.CheckpointNotFoundError{}) { 38 | return err 39 | } 40 | 41 | var sub *esdb.Subscription 42 | if m.isAllStream { 43 | sub, err = m.esdb.SubscribeToAll(ctx, m.getAllStreamOptions(position)) 44 | } else { 45 | sub, err = m.esdb.SubscribeToStream(ctx, m.streamName, m.getStreamOptions(position)) 46 | } 47 | if err != nil { 48 | return err 49 | } 50 | 51 | go func() { 52 | for { 53 | s := sub.Recv() 54 | if s.EventAppeared != nil { 55 | if s.EventAppeared.Event == nil { 56 | continue 57 | } 58 | 59 | eventType := s.EventAppeared.Event.EventType 60 | if strings.HasPrefix(eventType, "$") || strings.Contains(eventType, "async_command_handler") { 61 | continue 62 | } 63 | 64 | event, metadata, err := m.serde.Deserialize(s.EventAppeared) 65 | if err != nil { 66 | if event != nil { 67 | panic(err) 68 | } else { 69 | // ignore unknown event type 70 | continue 71 | } 72 | } 73 | 74 | for _, s := range m.subscriptions { 75 | s.Project(event, *metadata) 76 | } 77 | 78 | m.storeCheckpoint(s) 79 | } 80 | 81 | if s.SubscriptionDropped != nil { 82 | panic(s.SubscriptionDropped.Error) 83 | } 84 | } 85 | }() 86 | 87 | return nil 88 | } 89 | 90 | func (m SubscriptionManager) getAllStreamOptions(position *uint64) esdb.SubscribeToAllOptions { 91 | options := esdb.SubscribeToAllOptions{} 92 | if position == nil { 93 | options.From = esdb.Start{} 94 | } else { 95 | options.From = esdb.Position{ 96 | Commit: *position, 97 | Prepare: *position, 98 | } 99 | } 100 | return options 101 | } 102 | 103 | func (m SubscriptionManager) getStreamOptions(position *uint64) esdb.SubscribeToStreamOptions { 104 | options := esdb.SubscribeToStreamOptions{} 105 | if position == nil { 106 | options.From = esdb.Start{} 107 | } else { 108 | options.From = esdb.Revision(*position) 109 | } 110 | return options 111 | } 112 | 113 | func (m SubscriptionManager) storeCheckpoint(s *esdb.SubscriptionEvent) { 114 | var checkpoint uint64 115 | if m.isAllStream { 116 | checkpoint = s.EventAppeared.OriginalEvent().Position.Commit 117 | } else { 118 | checkpoint = s.EventAppeared.Event.EventNumber 119 | } 120 | err := m.checkpointStore.StoreCheckpoint(checkpoint) 121 | if err != nil { 122 | panic(err) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/EventStore/EventStore-Client-Go/esdb" 9 | "github.com/EventStore/training-introduction-go/application" 10 | "github.com/EventStore/training-introduction-go/controllers" 11 | "github.com/EventStore/training-introduction-go/domain/doctorday" 12 | "github.com/EventStore/training-introduction-go/eventsourcing" 13 | "github.com/EventStore/training-introduction-go/infrastructure" 14 | "github.com/EventStore/training-introduction-go/infrastructure/inmemory" 15 | "github.com/EventStore/training-introduction-go/infrastructure/mongodb" 16 | "github.com/EventStore/training-introduction-go/infrastructure/projections" 17 | "github.com/labstack/echo/v4" 18 | "github.com/labstack/echo/v4/middleware" 19 | "go.mongodb.org/mongo-driver/mongo" 20 | "go.mongodb.org/mongo-driver/mongo/options" 21 | ) 22 | 23 | const ( 24 | EsdbConnectionString = "esdb://localhost:2113?tls=false" 25 | MongoConnectionString = "mongodb://localhost" 26 | ) 27 | 28 | func main() { 29 | esdbClient, err := createESDBClient() 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | mongoClient, err := createMongoClient() 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | typeMapper := eventsourcing.NewTypeMapper() 40 | serde := infrastructure.NewEsEventSerde(typeMapper) 41 | eventStore := infrastructure.NewEsEventStore(esdbClient, "scheduling", serde) 42 | 43 | doctorday.RegisterTypes(typeMapper) 44 | 45 | dispatcher := getDispatcher(eventStore) 46 | mongoDatabase := mongoClient.Database("projections") 47 | availableSlotsRepo := mongodb.NewAvailableSlotsRepository(mongoDatabase) 48 | commandStore := infrastructure.NewEsCommandStore(eventStore, esdbClient, serde, dispatcher) 49 | 50 | dayArchiver := application.NewDayArchiverProcessManager( 51 | inmemory.NewColdStorage(), 52 | mongodb.NewArchivableDayRepository(mongoDatabase), 53 | commandStore, 54 | eventStore, 55 | -180*24*time.Hour, 56 | ) 57 | 58 | subManager := projections.NewSubscriptionManager( 59 | esdbClient, 60 | infrastructure.NewEsCheckpointStore(esdbClient, "DaySubscription", serde), 61 | serde, 62 | "$all", 63 | projections.NewProjector(application.NewAvailableSlotsProjection(availableSlotsRepo)), 64 | projections.NewProjector(dayArchiver)) 65 | 66 | err = subManager.Start(context.TODO()) 67 | if err != nil { 68 | panic(err) 69 | } 70 | 71 | err = commandStore.Start() 72 | if err != nil { 73 | panic(err) 74 | } 75 | 76 | e := echo.New() 77 | e.Use(middleware.Logger()) 78 | e.Use(middleware.Recover()) 79 | e.GET("/", hello) 80 | 81 | s := controllers.NewSlotsController(dispatcher, availableSlotsRepo, eventStore) 82 | s.Register("/api/", e) 83 | 84 | e.Logger.Fatal(e.Start(":5001")) 85 | } 86 | 87 | func hello(c echo.Context) error { 88 | return c.String(http.StatusOK, "Hello, Training!") 89 | } 90 | 91 | func createESDBClient() (*esdb.Client, error) { 92 | settings, err := esdb.ParseConnectionString(EsdbConnectionString) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | db, err := esdb.NewClient(settings) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return db, nil 103 | } 104 | 105 | func createMongoClient() (*mongo.Client, error) { 106 | return mongo.Connect(context.TODO(), options.Client().ApplyURI(MongoConnectionString)) 107 | } 108 | 109 | func getDispatcher(eventStore infrastructure.EventStore) *infrastructure.Dispatcher { 110 | aggregateStore := infrastructure.NewEsAggregateStore(eventStore, 5) 111 | dayRepository := doctorday.NewEventStoreDayRepository(aggregateStore) 112 | handlers := doctorday.NewHandlers(dayRepository) 113 | cmdHandlerMap := infrastructure.NewCommandHandlerMap(handlers) 114 | dispatcher := infrastructure.NewDispatcher(cmdHandlerMap) 115 | return &dispatcher 116 | } 117 | --------------------------------------------------------------------------------