diff --git a/cli/server/server.go b/cli/server/server.go index 70c52e3..3bd788c 100644 --- a/cli/server/server.go +++ b/cli/server/server.go @@ -2,6 +2,9 @@ package server import ( "context" + features "directory/internal/services/api/features" + listings "directory/internal/services/api/listings" + "fmt" "os" "os/signal" @@ -12,7 +15,6 @@ import ( "directory/internal/router" "directory/internal/services" - "directory/internal/services/api" "directory/internal/store/database" "directory/pkg/config" db "directory/pkg/database" @@ -53,10 +55,16 @@ func initialize(cfg *config.Config) (s *server.Server, err error) { db, err := db.Connect(cfg) var services []services.Service + // Stores + featureStore := database.NewFeatureStore(db) + listingStore := database.NewListingStore(db) + contactStore := database.NewContactsStore(db) + + // Services + featuresService := features.NewFeatureService(*featureStore, *listingStore) + listingService := listings.NewListingService(*listingStore, *contactStore) - divisionStore := database.NewDivisionStore(db) - divisionService := api.NewDivisionService(*divisionStore) - services = append(services, divisionService) + services = append(services, featuresService, listingService) var middlewares chi.Middlewares middlewares = append(middlewares, middleware.Logger) diff --git a/example.env b/example.env index 3f903d9..18bef2d 100644 --- a/example.env +++ b/example.env @@ -1,7 +1,7 @@ SERVER_ADDRESS=:6000 DATABASE_DRIVER=postgres -DATABASE_SOURCE= +DATABASE_SOURCE=postgres://:@:5432/directory?search_path=directory DATABASE_MAX_CONN_LIFETIME=1h DATABASE_MAX_CONNECTIONS=20 DATABASE_CONNECT_TIMEOUT=5s diff --git a/internal/services/api/divisions.go b/internal/services/api/divisions.go deleted file mode 100644 index 0323738..0000000 --- a/internal/services/api/divisions.go +++ /dev/null @@ -1,180 +0,0 @@ -package api - -import ( - "encoding/json" - "io" - "net/http" - "strconv" - - "github.com/go-chi/chi/v5" - - "directory/internal/store/database" - "directory/pkg/types" -) - -type DivisionService struct { - divisionStore database.DivisionStore -} - -func NewDivisionService(divisionsStore database.DivisionStore) *DivisionService { - return &DivisionService{ - divisionStore: divisionsStore, - } -} - -func (s *DivisionService) RegisterRoutes(mux *chi.Mux) { - mux.Post("/divisions", s.Create) - mux.Get("/divisions/{id}", s.FindByID) - mux.Put("/divisions/{id}", s.Update) - mux.Delete("/divisions/{id}", s.Delete) -} - -func (s *DivisionService) Create(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusInternalServerError) - return - } - - var data map[string]interface{} - if err := json.Unmarshal(body, &data); err != nil { - http.Error(w, "Failed to unmarshal body as JSON", http.StatusBadRequest) - return - } - - name := data["name"].(string) - divisionType := data["type"].(string) - parentId := int(data["parent_id"].(float64)) - - division := types.Division{ - Name: name, - Type: divisionType, - ParentId: &parentId, - } - - id, err := s.divisionStore.Create(ctx, division) - if err != nil { - http.Error(w, "Failed to create division", http.StatusInternalServerError) - return - } - division.Id = id - - response, err := json.Marshal(division) - if err != nil { - http.Error(w, "Error marshalling JSON", http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - _, err = w.Write(response) - if err != nil { - http.Error(w, "Error writing response", http.StatusInternalServerError) - return - } -} - -// TODO add query param for find 1 or recursive -func (s *DivisionService) FindByID(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - id, err := strconv.Atoi(chi.URLParam(r, "id")) - if err != nil { - http.Error(w, "division id is invalid", http.StatusBadRequest) - return - } - - divisions, err := s.divisionStore.FindRelationsByID(ctx, id) - if err != nil { - http.Error(w, "division id was not found", http.StatusNotFound) - } - - response, err := json.Marshal(divisions) - if err != nil { - http.Error(w, "Error marshalling JSON", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - _, err = w.Write(response) - if err != nil { - http.Error(w, "Error writing response", http.StatusInternalServerError) - return - } -} - -func (s *DivisionService) Update(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, err := strconv.Atoi(chi.URLParam(r, "id")) - if err != nil { - http.Error(w, "division id is invalid", http.StatusBadRequest) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to update request body", http.StatusInternalServerError) - return - } - - var data map[string]interface{} - - if err := json.Unmarshal(body, &data); err != nil { - http.Error(w, "Failed to unmarshal body as JSON", http.StatusBadRequest) - return - } - - division, err := s.divisionStore.FindByID(ctx, id) - if err != nil { - http.Error(w, "Failed to find division", http.StatusNotFound) - return - } - - if data["name"] != nil { - division.Name = data["name"].(string) - } - if data["type"] != nil { - division.Type = data["type"].(string) - } - if data["parent_id"] != nil { - parentId := int(data["parent_id"].(float64)) - division.ParentId = &parentId - } - - _, err = s.divisionStore.Update(ctx, *division) - if err != nil { - http.Error(w, "Failed to update division", http.StatusInternalServerError) - return - } - - response, err := json.Marshal(division) - if err != nil { - http.Error(w, "Error marshalling JSON", http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - _, err = w.Write(response) - - if err != nil { - http.Error(w, "Error updating division", http.StatusInternalServerError) - return - } -} - -func (s *DivisionService) Delete(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - id, err := strconv.Atoi(chi.URLParam(r, "id")) - if err != nil { - http.Error(w, "Invalid Division ID", http.StatusBadRequest) - return - } - - _, err = s.divisionStore.Delete(ctx, id) - if err != nil { - http.Error(w, "Cannot delete division", http.StatusConflict) - return - } - - w.WriteHeader(http.StatusNoContent) -} diff --git a/internal/services/api/features/features.go b/internal/services/api/features/features.go new file mode 100644 index 0000000..c650ad6 --- /dev/null +++ b/internal/services/api/features/features.go @@ -0,0 +1,96 @@ +package api + +import ( + "directory/pkg/types" + "encoding/json" + "github.com/go-chi/chi/v5" + "net/http" + + "directory/internal/store/database" +) + +type FeatureService struct { + featureStore database.FeatureStore + listingStore database.ListingStore +} + +func NewFeatureService(featuresStore database.FeatureStore, listingStore database.ListingStore) *FeatureService { + return &FeatureService{ + featureStore: featuresStore, + listingStore: listingStore, + } +} + +func (s *FeatureService) RegisterRoutes(mux *chi.Mux) { + mux.Get("/features/{id}", s.FindByID) + mux.Get("/features/{featureId}/listings", s.FindListingsByFeatureId) + +} + +func (s *FeatureService) FindByID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id := chi.URLParam(r, "id") + + features, err := s.featureStore.FindByID(ctx, id) + if err != nil { + http.Error(w, "feature id was not found", http.StatusNotFound) + } + + response, err := json.Marshal(features) + if err != nil { + http.Error(w, "Error marshalling JSON", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, err = w.Write(response) + if err != nil { + http.Error(w, "Error writing response", http.StatusInternalServerError) + return + } +} + +func (s *FeatureService) FindListingsByFeatureId(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + featureId := chi.URLParam(r, "featureId") + + featureTree, featureInternalIds, err := s.featureStore.FindRelationsByID(ctx, featureId) + if err != nil { + http.Error(w, "feature id was not found", http.StatusNotFound) + } + listings, err := s.listingStore.FindByListingFeatureInternalIDs(ctx, featureInternalIds) + if err != nil { + http.Error(w, "listings for feature ids were not found", http.StatusNotFound) + } + + listingMap := listingsToMap(listings) + for _, feature := range featureTree { + listing, match := listingMap[feature.InternalId] + if match { + feature.Listings = append(feature.Listings, listing) + } + } + + response, err := json.Marshal(featureTree) + if err != nil { + http.Error(w, "Error marshalling JSON", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, err = w.Write(response) + if err != nil { + http.Error(w, "Error writing response", http.StatusInternalServerError) + return + } +} + +func listingsToMap(listings []*types.Listing) map[int]*types.Listing { + i := map[int]*types.Listing{} + for _, listing := range listings { + i[listing.FeatureId] = listing + } + return i +} diff --git a/internal/services/api/listings/listings.go b/internal/services/api/listings/listings.go new file mode 100644 index 0000000..fc60d86 --- /dev/null +++ b/internal/services/api/listings/listings.go @@ -0,0 +1,48 @@ +package api + +import ( + "directory/internal/store/database" + "encoding/json" + "github.com/go-chi/chi/v5" + "net/http" +) + +type ListingService struct { + listingStore database.ListingStore + contactStore database.ContactsStore +} + +func NewListingService(listingStore database.ListingStore, contactStore database.ContactsStore) *ListingService { + return &ListingService{ + contactStore: contactStore, + listingStore: listingStore, + } +} + +func (s *ListingService) RegisterRoutes(mux *chi.Mux) { + mux.Get("/listing/{id}/contacts", s.FindContactByListingID) +} + +func (s *ListingService) FindContactByListingID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id := chi.URLParam(r, "id") + + contacts, err := s.contactStore.FindContactsByListingIDs(ctx, id) + if err != nil { + http.Error(w, "contacts were not found for the requested listing", http.StatusNotFound) + } + + response, err := json.Marshal(contacts) + if err != nil { + http.Error(w, "Error marshalling JSON", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, err = w.Write(response) + if err != nil { + http.Error(w, "Error writing response", http.StatusInternalServerError) + return + } +} diff --git a/internal/store/database/contacts.go b/internal/store/database/contacts.go new file mode 100644 index 0000000..ae97f1e --- /dev/null +++ b/internal/store/database/contacts.go @@ -0,0 +1,49 @@ +package database + +import ( + "context" + "fmt" + + db "directory/pkg/database" + "directory/pkg/types" +) + +type ContactsStore struct { + store db.Pool +} + +func NewContactsStore(s db.Pool) *ContactsStore { + return &ContactsStore{store: s} +} + +func (s *ContactsStore) FindContactsByListingIDs(ctx context.Context, listingId string) (contacts []*types.Contact, err error) { + rows, err := s.store.Query(ctx, findContactsByIDs, listingId) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + for rows.Next() { + var contact types.Contact + if err := rows.Scan(&contact.Id, &contact.Name, &contact.Type, &contact.Details); err != nil { + return nil, fmt.Errorf("row scan failed: %w", err) + } + contacts = append(contacts, &contact) + } + return +} + +const findContactsByIDs = ` + SELECT + id, + name, + type, + details + FROM contacts + WHERE internal_id + = ANY(SELECT unnest(contact_ids) FROM listings WHERE id = $1) +` + +// Notes +//LEFT JOIN LATERAL +//unnest(l.contact_ids) AS contact_id ON true +//LEFT JOIN +//contacts c ON c.internal_id = contact_id diff --git a/internal/store/database/divisions.go b/internal/store/database/divisions.go deleted file mode 100644 index fe0c44b..0000000 --- a/internal/store/database/divisions.go +++ /dev/null @@ -1,99 +0,0 @@ -package database - -import ( - "context" - "log" - - db "directory/pkg/database" - "directory/pkg/types" -) - -type DivisionStore struct { - store db.Pool -} - -func NewDivisionStore(s db.Pool) *DivisionStore { - return &DivisionStore{store: s} -} - -func (s *DivisionStore) Create(ctx context.Context, division types.Division) (createdId int, err error) { - err = s.store.QueryRow(ctx, createQuery, division.Name, division.Type, division.ParentId).Scan(&createdId) - return -} - -func (s *DivisionStore) FindByID(ctx context.Context, id int) (division *types.Division, err error) { - division = &types.Division{} - err = s.store.QueryRow(ctx, findByIDQuery, id).Scan(&division.Id, &division.Name, &division.Type, &division.ParentId) - return -} - -func (s *DivisionStore) Update(ctx context.Context, division types.Division) (updatedId int, err error) { - err = s.store.QueryRow(ctx, updateQuery, division.Id, division.Name, division.Type, division.ParentId).Scan(&updatedId) - return -} - -func (s *DivisionStore) Delete(ctx context.Context, id int) (deletedId int, err error) { - err = s.store.QueryRow(ctx, deleteQuery, id).Scan(&deletedId) - return -} - -func (s *DivisionStore) FindRelationsByID(ctx context.Context, id int) (divisions []*types.Division, err error) { - rows, err := s.store.Query(ctx, recursiveFindByIDQuery, id) - if err != nil { - log.Fatalf("failed to query rows: %v", err) - } - - for rows.Next() { - var d types.Division - err := rows.Scan(&d.Id, &d.Name, &d.Type, &d.ParentId) - if err != nil { - log.Fatalf("failed to scan row: %v", err) - } - divisions = append(divisions, &d) - } - - return -} - -const createQuery = "INSERT INTO directory.divisions (name, type, parent_id) VALUES ($1, $2, $3) RETURNING id" - -const findByIDQuery = "SELECT d.id, d.name, d.type, d.parent_id FROM directory.divisions d WHERE id = $1" - -const updateQuery = "UPDATE directory.divisions SET name = $2, type = $3, parent_id = $4 WHERE id = $1 RETURNING id" - -const deleteQuery = "DELETE FROM directory.divisions WHERE id = $1 RETURNING id" - -// WARNING Can this ever be circular? -const recursiveFindByIDQuery = ` - WITH RECURSIVE ParentCTE AS ( - -- Start with the given record and find its children - SELECT id, name, type, parent_id - FROM directory.divisions - WHERE id = $1 - - UNION ALL - - -- Find all parents of the current record - SELECT loc.id, loc.name, loc.type, loc.parent_id - FROM directory.divisions loc - JOIN ParentCTE p ON loc.id = p.parent_id - ), - ChildCTE AS ( - -- Start with the given record and find its children - SELECT id, name, type, parent_id - FROM directory.divisions - WHERE id = $1 - - UNION ALL - - -- Find all children of the current record - SELECT loc.id, loc.name, loc.type, loc.parent_id - FROM directory.divisions loc - JOIN ChildCTE c ON loc.parent_id = c.id - ) - -- Combine results from both ParentCTE and ChildCTE - SELECT * FROM ParentCTE - UNION - SELECT * FROM ChildCTE - ORDER BY id; -` diff --git a/internal/store/database/features.go b/internal/store/database/features.go new file mode 100644 index 0000000..461121e --- /dev/null +++ b/internal/store/database/features.go @@ -0,0 +1,100 @@ +package database + +import ( + "context" + "log" + + db "directory/pkg/database" + "directory/pkg/types" +) + +type FeatureStore struct { + store db.Pool +} + +func NewFeatureStore(s db.Pool) *FeatureStore { + return &FeatureStore{store: s} +} + +func (s *FeatureStore) Create(ctx context.Context, feature types.Feature) (createdId int, err error) { + err = s.store.QueryRow(ctx, createQuery, feature.Name, feature.Type, feature.ParentId).Scan(&createdId) + return +} + +func (s *FeatureStore) Update(ctx context.Context, feature types.Feature) (updatedId string, err error) { + err = s.store.QueryRow(ctx, updateQuery, feature.Id, feature.Name, feature.Type, feature.ParentId).Scan(&updatedId) + return +} + +func (s *FeatureStore) FindByID(ctx context.Context, id string) (feature *types.Feature, err error) { + feature = &types.Feature{} + err = s.store.QueryRow(ctx, findByIDQuery, id).Scan(&feature.Id, &feature.Name, &feature.Type, &feature.ParentId) + return +} + +func (s *FeatureStore) Delete(ctx context.Context, id string) (deletedId string, err error) { + err = s.store.QueryRow(ctx, deleteQuery, id).Scan(&deletedId) + return +} + +func (s *FeatureStore) FindRelationsByID(ctx context.Context, id string) (features []*types.Feature, featureIds []int, err error) { + rows, err := s.store.Query(ctx, recursiveFindByIDQuery, id) + if err != nil { + log.Fatalf("failed to query rows: %v", err) + } + for rows.Next() { + var feature types.Feature + err := rows.Scan(&feature.Id, &feature.InternalId, &feature.Name, &feature.Type, &feature.ParentId) + if err != nil { + log.Fatalf("failed to scan row: %v", err) + } + features = append(features, &feature) + featureIds = append(featureIds, feature.InternalId) + } + + return +} + +const createQuery = "INSERT INTO features (name, type, parent_id) VALUES ($1, $2, $3) RETURNING id" + +const findByIDQuery = "SELECT id, name, type, parent_id FROM features WHERE id = $1" + +const updateQuery = "UPDATE features SET name = $2, type = $3, parent_id = $4 WHERE id = $1 RETURNING id" + +const deleteQuery = "DELETE FROM features WHERE id = $1 RETURNING id" + +// WARNING Can this ever be circular? +// If a parent references a child incorrectly -> child references parent correctly +const recursiveFindByIDQuery = ` +WITH RECURSIVE ParentCTE AS ( + -- Start with the given record and find its children + SELECT internal_id, id, name, type, parent_id + FROM features + WHERE id = $1 + + UNION ALL + + -- Find all parents of the current record + SELECT loc.internal_id, loc.id, loc.name, loc.type, loc.parent_id + FROM features loc + JOIN ParentCTE p ON loc.internal_id = p.parent_id + ), + ChildCTE AS ( + -- Start with the given record and find its children + SELECT internal_id, id, name, type, parent_id + FROM features + WHERE id = $1 + + UNION ALL + + -- Find all children of the current record + SELECT loc.internal_id, loc.id, loc.name, loc.type, loc.parent_id + FROM features loc + JOIN ChildCTE c ON loc.parent_id = c.internal_id + ) + -- Combine results from both ParentCTE and ChildCTE + SELECT id, internal_id, name, type, parent_id FROM ParentCTE + UNION + SELECT id, internal_id, name, type, parent_id FROM ChildCTE + ORDER BY id; +` diff --git a/internal/store/database/listings.go b/internal/store/database/listings.go new file mode 100644 index 0000000..1b60272 --- /dev/null +++ b/internal/store/database/listings.go @@ -0,0 +1,47 @@ +package database + +import ( + "context" + "fmt" + + db "directory/pkg/database" + "directory/pkg/types" +) + +type ListingStore struct { + store db.Pool +} + +func NewListingStore(s db.Pool) *ListingStore { + return &ListingStore{store: s} +} + +func (s *ListingStore) FindByListingFeatureInternalIDs(ctx context.Context, featureIds []int) (listings []*types.Listing, err error) { + rows, err := s.store.Query(ctx, findContactsByListingIDs, featureIds) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + + for rows.Next() { + var listing types.Listing + if err := rows.Scan(&listing.Id, &listing.Name, &listing.Type, &listing.FeatureId, &listing.Address, &listing.Details, &listing.ContactIds); err != nil { + return nil, fmt.Errorf("row scan failed: %w", err) + } + listings = append(listings, &listing) + } + return +} + +const findContactsByListingIDs = ` + SELECT + l.id, + l.name, + l.type, + l.feature_id, + l.address, + l.details, + l.contact_ids + FROM + listings l + WHERE l.feature_id = ANY($1) +` diff --git a/pkg/types/consts.go b/pkg/types/consts.go index ab1254f..fe3a1e0 100644 --- a/pkg/types/consts.go +++ b/pkg/types/consts.go @@ -1 +1,44 @@ package types + +type ListingType string + +const ( + POLICE ListingType = "Police" + HOSPITAL ListingType = "Hospital" + FIRE_DEPARTMENT ListingType = "Fire Department" + AMBULANCE ListingType = "Ambulance" + POISON_CONTROL ListingType = "Poison Control" + COAST_GUARD ListingType = "Coast Guard" + ELECTRICITY_EMERGENCY ListingType = "Electricity Emergency" + GAS_LEAK_EMERGENCY ListingType = "Gas Leak Emergency" + ROAD_ASSISTANCE ListingType = "Road Assistance" + MENTAL_HEALTH ListingType = "Mental Health" + DOMESTIC_VIOLENCE ListingType = "Domestic Violence" + MISCELLANEOUS ListingType = "Miscellaneous" +) + +type FeatureType string + +const ( + COUNTRY FeatureType = "country" + REGION FeatureType = "region" + POSTCODE FeatureType = "postcode" + DISTRICT FeatureType = "district" + PLACE FeatureType = "place" + LOCALITY FeatureType = "locality" + NEIGHBORHOOD FeatureType = "neighborhood" +) + +type ContactType string + +const ( + EMAIL ContactType = "Email" + PHONE ContactType = "Phone" +) + +type AdType string + +const ( + LAWYER AdType = "Lawyer" + DOCTOR AdType = "Doctor" +) diff --git a/pkg/types/types.go b/pkg/types/types.go index 377193b..2745511 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -1,66 +1,47 @@ package types -type Division struct { - Id int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - ParentId *int `json:"parent_id"` -} - type Type string -const ( - COUNTRY Type = "country" - STATE Type = "state" - PROVINCE Type = "province" - OBLAST Type = "oblast" - LAND Type = "land" - REGION Type = "region" - COMARCA Type = "comarca" - RAION Type = "raion" - DISTRICT Type = "district" - MUNICIPALITY Type = "municipality" - COMMUNE Type = "commune" - COMMUNITY Type = "community" - DEPARTMENT Type = "department" - CANTON Type = "canton" - PREFECTURE Type = "prefecture" - COUNTY Type = "county" - GOVERNORATE Type = "governorate" -) - -type Directory struct { - Country string `json:"country"` - State string `json:"state"` - City string `json:"city"` - - Listings []*Listing `json:"listings"` - Ads []*Ad `json:"ads"` +type Feature struct { + Id string `json:"id"` + InternalId int `json:",omitempty" db:"internal_id"` + Name string `json:"name"` + Type FeatureType `json:"type"` + ParentId *int `json:"parent_id"` + Listings []*Listing `json:"listings"` } type Listing struct { - Type ListingType `json:"type"` - Name string `json:"name"` - Phone string `json:"phone"` + Id string `json:"id"` + InternalId int `json:",omitempty" db:"internal_id"` + Name string `json:"name"` + Type ListingType `json:"type"` + FeatureId int `json:"feature_id"` + Address string `json:"address"` + ContactIds []int `json:"contact_ids"` + Details *string `json:"details"` + Contacts []Contact `json:"contacts"` +} + +type Contact struct { + Id string `json:"id"` + InternalId int `json:",omitempty" db:"internal_id"` + Name string `json:"name"` + Type ContactType `json:"type"` + Details *string `json:"details"` } +// TODO Implement these structs type Ad struct { Type AdType `json:"type"` Name string `json:"name"` Phone string `json:"phone"` } -type ListingType string - -const ( - POLICE ListingType = "Police" - FIRE ListingType = "Fire" - EMS ListingType = "EMS" -) - -type AdType string - -const ( - LAWYER AdType = "Lawyer" - DOCTOR AdType = "Doctor" -) +type Directory struct { + Country string `json:"country"` + State string `json:"state"` + City string `json:"city"` + Listings []*Listing `json:"listings"` + Ads []*Ad `json:"ads"` +}