From 06b2b3478d29ae1543f4500b7cdf2705f7e2be99 Mon Sep 17 00:00:00 2001 From: Mohammad Amin Date: Tue, 31 Mar 2026 22:07:49 +0330 Subject: [PATCH] Implement patient list end point --- patientapp/app.go | 33 ++ patientapp/config.go | 4 + patientapp/delivery/http/analytic/.gitkeep | 0 .../delivery/http/analytic/dto/param.go | 70 ++++ patientapp/delivery/http/analytic/handler.go | 155 +++++++++ patientapp/delivery/http/analytic/helper.go | 70 ++++ patientapp/delivery/http/analytic/router.go | 17 + patientapp/delivery/http/analytic/server.go | 23 ++ patientapp/repository/database/.gitkeep | 0 .../repository/database/analytic_repo.go | 303 ++++++++++++++++++ patientapp/repository/database/dbconfig.yml | 10 + patientapp/repository/database/helper.go | 33 ++ patientapp/service/analytic/.gitkeep | 0 patientapp/service/analytic/helper.go | 32 ++ patientapp/service/analytic/patient_filter.go | 29 ++ patientapp/service/analytic/service.go | 128 ++++++++ patientapp/service/analytic/validator.go | 4 + patientapp/service/entity/address.go | 28 ++ patientapp/service/entity/map_summary.go | 17 + patientapp/service/entity/patient.go | 64 ++++ patientapp/tree.txt | Bin 0 -> 10 bytes 21 files changed, 1020 insertions(+) create mode 100644 patientapp/config.go delete mode 100644 patientapp/delivery/http/analytic/.gitkeep create mode 100644 patientapp/delivery/http/analytic/dto/param.go create mode 100644 patientapp/delivery/http/analytic/handler.go create mode 100644 patientapp/delivery/http/analytic/helper.go create mode 100644 patientapp/delivery/http/analytic/router.go create mode 100644 patientapp/delivery/http/analytic/server.go delete mode 100644 patientapp/repository/database/.gitkeep create mode 100644 patientapp/repository/database/analytic_repo.go create mode 100644 patientapp/repository/database/dbconfig.yml create mode 100644 patientapp/repository/database/helper.go delete mode 100644 patientapp/service/analytic/.gitkeep create mode 100644 patientapp/service/analytic/helper.go create mode 100644 patientapp/service/analytic/patient_filter.go create mode 100644 patientapp/service/analytic/service.go create mode 100644 patientapp/service/analytic/validator.go create mode 100644 patientapp/service/entity/address.go create mode 100644 patientapp/service/entity/map_summary.go create mode 100644 patientapp/service/entity/patient.go create mode 100644 patientapp/tree.txt diff --git a/patientapp/app.go b/patientapp/app.go index 29d637af..4400d98d 100644 --- a/patientapp/app.go +++ b/patientapp/app.go @@ -1 +1,34 @@ package patientapp + +import ( + "net/http" + + "git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic" + "git.gocasts.ir/ebhomengo/niki/patientapp/repository/database" +) + +type Application struct { + Config Config + HTTPServer *http.Server + DB *database.DataBase + Router http.Handler +} + +func Setup(config Config, server *http.Server, conn *database.DataBase) Application { + handler := analytic.NewHandler() + router := analytic.NewRouter(handler) + + return Application{ + Config: config, + HTTPServer: server, + DB: conn, + Router: router, + } +} + +func (a *Application) Start() { + srv := &http.Server{Addr: a.HTTPServer.Addr} + server := analytic.NewServer(srv, a.Router) + + _ = server.Serve() +} diff --git a/patientapp/config.go b/patientapp/config.go new file mode 100644 index 00000000..9bddc704 --- /dev/null +++ b/patientapp/config.go @@ -0,0 +1,4 @@ +package patientapp + +type Config struct { +} diff --git a/patientapp/delivery/http/analytic/.gitkeep b/patientapp/delivery/http/analytic/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/patientapp/delivery/http/analytic/dto/param.go b/patientapp/delivery/http/analytic/dto/param.go new file mode 100644 index 00000000..efd3bac6 --- /dev/null +++ b/patientapp/delivery/http/analytic/dto/param.go @@ -0,0 +1,70 @@ +package dto + +import ( + "time" + + "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" +) + +type ListPatientAnalyticRequest struct { + // All fields are optional + MinAge *int `json:"minAge,omitempty"` + MaxAge *int `json:"maxAge,omitempty"` + Sex *entity.Sex `json:"sex,omitempty"` + + City *int64 `json:"city,omitempty"` + Province *int64 `json:"province,omitempty"` + + Search *string `json:"search,omitempty"` + + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +type PatientAnalyticItem struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"Last_name"` + DateOfBirth *time.Time `json:"dob,omitempty"` + Sex entity.Sex `json:"sex"` + Phone string `json:"phone"` + Address entity.Address `json:"address"` +} +type PatientAnalyticResponse struct { + Items []PatientAnalyticItem `json:"items"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` +} + +func ToPatientResponse(patient entity.Patient) PatientAnalyticItem { + return PatientAnalyticItem{ + ID: patient.ID, + FirstName: patient.FirstName, + LastName: patient.LastName, + DateOfBirth: patient.DateOfBirth, + Sex: patient.Sex, + Phone: patient.Phone, + Address: entity.Address{ + ProvinceID: patient.Address.ProvinceID, + CityID: patient.Address.CityID, + }, + } +} + +// =========================== Map ================================== + +type GetPatientMapSummaryRequest struct { + Level entity.MapLevel `json:"level"` + ParentID *int `json:"parentID"` + + MinAge *int `json:"minAge,omitempty"` + MaxAge *int `json:"maxAge,omitempty"` + Sex *entity.Sex `json:"sex,omitempty"` + Search *string `json:"search,omitempty"` +} + +type GetPatientMapSummaryResponse struct { + Level entity.MapLevel `json:"level"` + Items []entity.MapSummaryItem `json:"items"` +} diff --git a/patientapp/delivery/http/analytic/handler.go b/patientapp/delivery/http/analytic/handler.go new file mode 100644 index 00000000..827db2c1 --- /dev/null +++ b/patientapp/delivery/http/analytic/handler.go @@ -0,0 +1,155 @@ +package analytic + +import ( + "fmt" + "log" + "net/http" + "strings" + + "git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic/dto" + svc "git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic" +) + +type Handler struct { + service svc.Service +} + +func NewHandler(service svc.Service) *Handler { + return &Handler{ + service: service, + } +} + +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +func (h *Handler) PatientsAnalytic(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + minAge, err := parseIntPtr(q, "minAge") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid minAge") + return + } + maxAge, err := parseIntPtr(q, "maxAge") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid maxAge") + return + } + + sex, err := parseSexPtr(q, "sex") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid sex") + return + } + + provinceID, err := parseIntPtr(q, "provinceId") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid provinceId") + return + } + cityID, err := parseIntPtr(q, "cityId") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid cityId") + return + } + + search := strings.TrimSpace(q.Get("search")) + var searchPtr *string + if search != "" { + searchPtr = &search + } + + limit, err := parseInt(q, "limit") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid limit") + return + } + offset, err := parseInt(q, "offset") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid offset") + return + } + + pId := int64(*provinceID) + cId := int64(*cityID) + + req := dto.ListPatientAnalyticRequest{ + MinAge: minAge, + MaxAge: maxAge, + Sex: sex, + Province: &pId, + City: &cId, + Search: searchPtr, + Limit: limit, + Offset: offset, + } + + response, err := h.service.List(r.Context(), req) + if err != nil { + log.Println("List error:", err) + writeError(w, http.StatusInternalServerError, fmt.Errorf("List error: %w", err).Error()) + return + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) PatientsMapSummary(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + level, err := parseMapLevel(q, "level") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid level (city|province|country)") + return + } + + parentID, err := parseIntPtr(q, "parentId") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid parentId") + return + } + + minAge, err := parseIntPtr(q, "minAge") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid minAge") + return + } + maxAge, err := parseIntPtr(q, "maxAge") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid maxAge") + return + } + + sex, err := parseSexPtr(q, "sex") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid sex") + return + } + + search := strings.TrimSpace(q.Get("search")) + var searchPtr *string + if search != "" { + searchPtr = &search + } + + req := dto.GetPatientMapSummaryRequest{ + Level: level, + ParentID: parentID, + MinAge: minAge, + MaxAge: maxAge, + Sex: sex, + Search: searchPtr, + } + + resp, svcErr := h.service.GetMapSummary(r.Context(), req) + if svcErr != nil { + log.Println("GetMapSummary error:", svcErr) + writeError(w, http.StatusInternalServerError, svcErr.Error()) + return + } + + writeJSON(w, http.StatusOK, resp) +} diff --git a/patientapp/delivery/http/analytic/helper.go b/patientapp/delivery/http/analytic/helper.go new file mode 100644 index 00000000..9058409a --- /dev/null +++ b/patientapp/delivery/http/analytic/helper.go @@ -0,0 +1,70 @@ +package analytic + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"error": msg}) +} + +func parseInt(q urlValues, key string) (int, error) { + val := strings.TrimSpace(q.Get(key)) + if val == "" { + return 0, nil + } + return strconv.Atoi(val) +} + +type urlValues interface { + Get(key string) string +} + +func parseIntPtr(q urlValues, key string) (*int, error) { + val := strings.TrimSpace(q.Get(key)) + if val == "" { + return nil, nil + } + i, err := strconv.Atoi(val) + if err != nil { + return nil, err + } + return &i, nil +} + +func parseSexPtr(q urlValues, key string) (*entity.Sex, error) { + val := strings.TrimSpace(q.Get(key)) + if val == "" { + return nil, nil + } + s := entity.Sex(val) + if !s.SexValidation() { + return nil, strconv.ErrSyntax + } + return &s, nil +} + +func parseMapLevel(q urlValues, key string) (entity.MapLevel, error) { + val := strings.TrimSpace(q.Get(key)) + if val == "" { + return "", strconv.ErrSyntax + } + l := entity.MapLevel(val) + switch l { + case entity.MapLevelCity, entity.MapLevelProvince, entity.MapLevelCountry: + return l, nil + default: + return "", strconv.ErrSyntax + } +} diff --git a/patientapp/delivery/http/analytic/router.go b/patientapp/delivery/http/analytic/router.go new file mode 100644 index 00000000..6bc5b325 --- /dev/null +++ b/patientapp/delivery/http/analytic/router.go @@ -0,0 +1,17 @@ +package analytic + +import "net/http" + +func NewRouter(h *Handler) http.Handler { + mux := http.NewServeMux() + + // Define Routes + v1 := http.NewServeMux() + v1.HandleFunc("GET /health", h.Health) + v1.HandleFunc("GET /patients", h.PatientsAnalytic) + v1.HandleFunc("GET /patients-summary", h.PatientsMapSummary) + + mux.Handle("/v1/", http.StripPrefix("/v1", v1)) + + return mux +} diff --git a/patientapp/delivery/http/analytic/server.go b/patientapp/delivery/http/analytic/server.go new file mode 100644 index 00000000..4903eeb4 --- /dev/null +++ b/patientapp/delivery/http/analytic/server.go @@ -0,0 +1,23 @@ +package analytic + +import ( + "fmt" + "net/http" +) + +type Server struct { + HTTPServer *http.Server +} + +func NewServer(server *http.Server, router http.Handler) *Server { + server.Handler = router + return &Server{ + HTTPServer: server, + } +} + +func (s Server) Serve() error { + // Start server + fmt.Printf("start sever on %s \n", s.HTTPServer.Addr) + return s.HTTPServer.ListenAndServe() +} diff --git a/patientapp/repository/database/.gitkeep b/patientapp/repository/database/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/patientapp/repository/database/analytic_repo.go b/patientapp/repository/database/analytic_repo.go new file mode 100644 index 00000000..9c72ae0d --- /dev/null +++ b/patientapp/repository/database/analytic_repo.go @@ -0,0 +1,303 @@ +package database + +import ( + "context" + "database/sql" + "strings" + + "git.gocasts.ir/ebhomengo/niki/patientapp/service/analytic" + "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" +) + +type DataBase struct { + conn *mysql.DB +} + +func NewPatientRepo(conn *mysql.DB) *DataBase { + return &DataBase{ + conn: conn, + } +} + +func (db *DataBase) GetPatients(ctx context.Context, f analytic.PatientFilter) ([]entity.Patient, error) { + const op = "mysqlpatient.GetPatients" + limit, offset := clampLimitOffset(f.Limit, f.Offset) + + q := ` +SELECT + p.id, + p.first_name, + p.last_name, + p.date_of_birth, + p.sex, + p.phone, + + p.country_id, + p.province_id, + p.city_id, + p.postal_code, + p.address_line1, + p.address_line2, + + p.case_status, + p.referral_source, + p.assigned_staff_id, + p.start_date, + p.end_date +FROM patients p +WHERE p.deleted_at IS NULL +` + + stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q) + if err != nil { + return nil, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected) + } + args := make([]any, 0) + + // filters + if f.Sex != nil { + q += " AND p.sex = ?" + args = append(args, string(*f.Sex)) + } + if f.DOBFrom != nil { + q += " AND p.date_of_birth >= ?" + args = append(args, f.DOBFrom.Format("2006-01-02")) + } + if f.DOBTo != nil { + q += " AND p.date_of_birth <= ?" + args = append(args, f.DOBTo.Format("2006-01-02")) + } + if f.Country != nil { + q += " AND p.country_id = ?" + args = append(args, *f.Country) + } + if f.Province != nil { + q += " AND p.province_id = ?" + args = append(args, *f.Province) + } + if f.City != nil { + q += " AND p.city_id = ?" + args = append(args, *f.City) + } + + if f.Search != nil && strings.TrimSpace(*f.Search) != "" { + term := "%" + strings.TrimSpace(*f.Search) + "%" + q += " AND (p.first_name LIKE ? OR p.last_name LIKE ? OR p.phone LIKE ?)" + args = append(args, term, term, term) + } + + q += " ORDER BY p.id DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + rows, err := stmt.QueryContext(ctx, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]entity.Patient, 0, limit) + + for rows.Next() { + var p entity.Patient + var dob sql.NullTime + var provinceID, cityID uint + + if err := rows.Scan( + &p.ID, + &p.FirstName, + &p.LastName, + &dob, + &p.Sex, + &p.Phone, + + &provinceID, + &cityID, + &p.Address.PostalCode, + + &p.CaseStatus, + &p.ReferralSource, + &p.AssignedStaffId, + &p.StartDate, + &p.EndDate, + ); err != nil { + return nil, err + } + + if dob.Valid { + t := dob.Time + p.DateOfBirth = &t + } + + p.Address.ProvinceID = provinceID + p.Address.CityID = cityID + + out = append(out, p) + } + + return out, rows.Err() +} + +func (db *DataBase) CountPatients(ctx context.Context, f analytic.PatientFilter) (int, error) { + const op = "mysqlpatient.CountPatients" + q := `SELECT COUNT(*) FROM patients p WHERE p.deleted_at IS NULL` + args := make([]any, 0) + + stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q) + if err != nil { + return 0, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected) + } + + if f.Sex != nil { + q += " AND p.sex = ?" + args = append(args, string(*f.Sex)) + } + if f.DOBFrom != nil { + q += " AND p.date_of_birth >= ?" + args = append(args, f.DOBFrom.Format("2006-01-02")) + } + if f.DOBTo != nil { + q += " AND p.date_of_birth <= ?" + args = append(args, f.DOBTo.Format("2006-01-02")) + } + if f.Country != nil { + q += " AND p.country_id = ?" + args = append(args, *f.Country) + } + if f.Province != nil { + q += " AND p.province_id = ?" + args = append(args, *f.Province) + } + if f.City != nil { + q += " AND p.city_id = ?" + args = append(args, *f.City) + } + + var total int + + err = stmt.QueryRowContext(ctx, args...).Scan(&total) + return total, err +} + +func (db *DataBase) SummaryByCity(ctx context.Context, provinceID int, f analytic.PatientMapFilter) ([]entity.MapSummaryItem, error) { + const op = "mysqlpatient.SummaryByCity" + q := ` +SELECT + c.id AS location_id, + c.name, + c.centroid_lat, + c.centroid_lng, + COUNT(p.id) AS count +FROM locations c +JOIN patients p ON p.city_id = c.id +WHERE c.level = 'city' + AND c.parent_id = ? + AND p.deleted_at IS NULL +` + + stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q) + if err != nil { + return nil, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected) + } + args := []any{provinceID} + + if f.Sex != nil { + q += " AND p.sex = ?" + args = append(args, string(*f.Sex)) + } + if f.MinDOB != nil { + q += " AND p.date_of_birth >= ?" + args = append(args, f.MinDOB.Format("2006-01-02")) + } + if f.MaxDOB != nil { + q += " AND p.date_of_birth <= ?" + args = append(args, f.MaxDOB.Format("2006-01-02")) + } + if f.Search != nil && strings.TrimSpace(*f.Search) != "" { + term := "%" + strings.TrimSpace(*f.Search) + "%" + q += " AND (p.first_name LIKE ? OR p.last_name LIKE ? OR p.phone LIKE ?)" + args = append(args, term, term, term) + } + + q += ` GROUP BY c.id, c.name, c.centroid_lat, c.centroid_lng + ORDER BY count DESC` + + rows, err := stmt.QueryContext(ctx, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []entity.MapSummaryItem + for rows.Next() { + var it entity.MapSummaryItem + if err := rows.Scan(&it.LocationID, &it.Name, &it.CentroidLat, &it.CentroidLng, &it.Count); err != nil { + return nil, err + } + out = append(out, it) + } + return out, rows.Err() +} + +func (db *DataBase) SummaryByProvince(ctx context.Context, countryID int, f analytic.PatientMapFilter) ([]entity.MapSummaryItem, error) { + const op = "mysqlpatient.SummaryByProvince" + + q := ` +SELECT + prov.id AS location_id, + prov.name, + prov.centroid_lat, + prov.centroid_lng, + COUNT(p.id) AS count +FROM patients p +JOIN locations city ON city.id = p.city_id AND city.level = 'city' +JOIN locations prov ON prov.id = city.parent_id AND prov.level = 'province' +WHERE prov.parent_id = ? + AND p.deleted_at IS NULL +` + + stmt, err := db.conn.PrepareStatement(ctx, mysql.StatementKeyCityGetProvinceIDByID, q) + if err != nil { + return nil, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantPrepareStatement).WithKind(richerror.KindUnexpected) + } + + args := []any{countryID} + + if f.Sex != nil { + q += " AND p.sex = ?" + args = append(args, string(*f.Sex)) + } + if f.MinDOB != nil { + q += " AND p.date_of_birth >= ?" + args = append(args, f.MinDOB.Format("2006-01-02")) + } + if f.MaxDOB != nil { + q += " AND p.date_of_birth <= ?" + args = append(args, f.MaxDOB.Format("2006-01-02")) + } + + q += ` GROUP BY prov.id, prov.name, prov.centroid_lat, prov.centroid_lng + ORDER BY count DESC` + + rows, err := stmt.QueryContext(ctx, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []entity.MapSummaryItem + for rows.Next() { + var it entity.MapSummaryItem + if err := rows.Scan(&it.LocationID, &it.Name, &it.CentroidLat, &it.CentroidLng, &it.Count); err != nil { + return nil, err + } + out = append(out, it) + } + return out, rows.Err() +} diff --git a/patientapp/repository/database/dbconfig.yml b/patientapp/repository/database/dbconfig.yml new file mode 100644 index 00000000..1e5a8a42 --- /dev/null +++ b/patientapp/repository/database/dbconfig.yml @@ -0,0 +1,10 @@ +postgres_db: + driver: postgres + host: patient-db + port: 5432 + user: patient_admin + password: password1234 + db_name: patient_db + ssl_mode: disable + +path_of_migration: ./patientapp/repository/database/migrations \ No newline at end of file diff --git a/patientapp/repository/database/helper.go b/patientapp/repository/database/helper.go new file mode 100644 index 00000000..b0ea1e0a --- /dev/null +++ b/patientapp/repository/database/helper.go @@ -0,0 +1,33 @@ +package database + +import "strings" + +type whereClause struct { + sql strings.Builder + args []any +} + +func (w *whereClause) add(cond string, args ...any) { + if w.sql.Len() == 0 { + w.sql.WriteString(" WHERE ") + } else { + w.sql.WriteString(" AND ") + } + w.sql.WriteString(cond) + w.args = append(w.args, args...) +} + +func (w *whereClause) String() string { return w.sql.String() } + +func clampLimitOffset(limit, offset int) (int, int) { + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + return limit, offset +} diff --git a/patientapp/service/analytic/.gitkeep b/patientapp/service/analytic/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/patientapp/service/analytic/helper.go b/patientapp/service/analytic/helper.go new file mode 100644 index 00000000..1e0b3df8 --- /dev/null +++ b/patientapp/service/analytic/helper.go @@ -0,0 +1,32 @@ +package analytic + +import "time" + +func normalizeLimitOffset(limit, offset int) (int, int) { + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + +// convert age range -> DOB range +func ageRangeToDOB(minAge, maxAge *int, now time.Time) (dobFrom, dobTo *time.Time) { + // maxAge => DOB >= now-(maxAge+1y)+1day + // eg: now is : 3/31/2026; maxAge=30 => (now - 31)= 3/31/1994 => +=1 + if maxAge != nil { + t := now.AddDate(-*maxAge-1, 0, 0).Add(24 * time.Hour) + dobFrom = &t + } + // minAge => DOB <= now-minAge + if minAge != nil { + t := now.AddDate(-*minAge, 0, 0) + dobTo = &t + } + return +} diff --git a/patientapp/service/analytic/patient_filter.go b/patientapp/service/analytic/patient_filter.go new file mode 100644 index 00000000..6ecaf1c0 --- /dev/null +++ b/patientapp/service/analytic/patient_filter.go @@ -0,0 +1,29 @@ +package analytic + +import ( + "time" + + "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" +) + +type PatientFilter struct { + DOBFrom *time.Time // born after + DOBTo *time.Time // born before + Sex *entity.Sex + + City *int64 + Province *int64 + Country *int64 + + Search *string + + Limit int + Offset int +} + +type PatientMapFilter struct { + MinDOB *time.Time + MaxDOB *time.Time + Sex *entity.Sex + Search *string +} diff --git a/patientapp/service/analytic/service.go b/patientapp/service/analytic/service.go new file mode 100644 index 00000000..2547fb82 --- /dev/null +++ b/patientapp/service/analytic/service.go @@ -0,0 +1,128 @@ +package analytic + +import ( + "context" + "errors" + "fmt" + "time" + + analytic2 "git.gocasts.ir/ebhomengo/niki/patientapp/delivery/http/analytic/dto" + "git.gocasts.ir/ebhomengo/niki/patientapp/service/entity" +) + +var ( + ErrInvalidProvinceID = errors.New("invalid province id") + ErrInvalidCountryID = errors.New("invalid country id") + ErrInvalidMapLevel = errors.New("invalid map level") +) + +type Repository interface { + GetPatients(ctx context.Context, f PatientFilter) ([]entity.Patient, error) + CountPatients(ctx context.Context, f PatientFilter) (int, error) + + SummaryByCity(ctx context.Context, provinceID int, f PatientMapFilter) ([]entity.MapSummaryItem, error) + SummaryByProvince(ctx context.Context, countryID int, f PatientMapFilter) ([]entity.MapSummaryItem, error) + SummaryByCountry(ctx context.Context, f PatientMapFilter) ([]entity.MapSummaryItem, error) +} + +type Service struct { + repository Repository + validator Validator +} + +func NewPatientAnalyticService(repo Repository, validator Validator) Service { + return Service{ + repository: repo, + validator: validator, + } +} + +func (s Service) List(ctx context.Context, req analytic2.ListPatientAnalyticRequest) (analytic2.PatientAnalyticResponse, error) { + + limit, offset := normalizeLimitOffset(req.Limit, req.Offset) + + // convert age range + dobFrom, dobTo := ageRangeToDOB(req.MinAge, req.MaxAge, time.Now()) + + filter := PatientFilter{ + DOBFrom: dobFrom, + DOBTo: dobTo, + Sex: req.Sex, + City: req.City, + Province: req.Province, + Search: req.Search, + Limit: limit, + Offset: offset, + } + + items, err := s.repository.GetPatients(ctx, filter) + if err != nil { + return analytic2.PatientAnalyticResponse{}, fmt.Errorf("GetPatients: %w", err) + } + + total, err := s.repository.CountPatients(ctx, filter) + if err != nil { + return analytic2.PatientAnalyticResponse{}, fmt.Errorf("CountPatients: %w", err) + } + + // mapping response + out := make([]analytic2.PatientAnalyticItem, 0, len(items)) + for _, value := range items { + out = append(out, analytic2.ToPatientResponse(value)) + } + + return analytic2.PatientAnalyticResponse{ + Items: out, + Limit: limit, + Offset: offset, + Total: total, + }, nil + +} + +func (s Service) GetMapSummary(ctx context.Context, req analytic2.GetPatientMapSummaryRequest) (analytic2.GetPatientMapSummaryResponse, error) { + + dobFrom, dobTo := ageRangeToDOB(req.MinAge, req.MaxAge, time.Now()) + + filter := PatientMapFilter{ + MinDOB: dobFrom, + MaxDOB: dobTo, + Sex: req.Sex, + Search: req.Search, + } + + switch req.Level { + case entity.MapLevelCity: + if req.ParentID == nil || *req.ParentID <= 0 { + return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidProvinceID + } + + items, err := s.repository.SummaryByCity(ctx, *req.ParentID, filter) + if err != nil { + return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByCity: %w", err) + } + return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil + + case entity.MapLevelProvince: + if req.ParentID == nil || *req.ParentID <= 0 { + return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidCountryID + } + + items, err := s.repository.SummaryByProvince(ctx, *req.ParentID, filter) + if err != nil { + return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByProvince: %w", err) + } + return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil + + case entity.MapLevelCountry: + items, err := s.repository.SummaryByCountry(ctx, filter) + if err != nil { + return analytic2.GetPatientMapSummaryResponse{}, fmt.Errorf("SummaryByCountry: %w", err) + } + return analytic2.GetPatientMapSummaryResponse{Level: req.Level, Items: items}, nil + + default: + return analytic2.GetPatientMapSummaryResponse{}, ErrInvalidMapLevel + } + +} diff --git a/patientapp/service/analytic/validator.go b/patientapp/service/analytic/validator.go new file mode 100644 index 00000000..91079f66 --- /dev/null +++ b/patientapp/service/analytic/validator.go @@ -0,0 +1,4 @@ +package analytic + +type Validator struct { +} diff --git a/patientapp/service/entity/address.go b/patientapp/service/entity/address.go new file mode 100644 index 00000000..193531a9 --- /dev/null +++ b/patientapp/service/entity/address.go @@ -0,0 +1,28 @@ +package entity + +type Address struct { + ID uint + PostalCode string + Address string + Name string + Lat float64 + Lon float64 + CityID uint + ProvinceID uint +} + +type AddressAggregated struct { + Address Address + Province Province + City City +} + +type Province struct { + ID uint + Name string +} +type City struct { + ID uint + Name string + ProvinceID uint +} diff --git a/patientapp/service/entity/map_summary.go b/patientapp/service/entity/map_summary.go new file mode 100644 index 00000000..fcfd1284 --- /dev/null +++ b/patientapp/service/entity/map_summary.go @@ -0,0 +1,17 @@ +package entity + +type MapLevel string + +const ( + MapLevelCity MapLevel = "city" + MapLevelProvince MapLevel = "province" + MapLevelCountry MapLevel = "country" +) + +type MapSummaryItem struct { + LocationID int64 + Name string + Count int + CentroidLat float64 + CentroidLng float64 +} diff --git a/patientapp/service/entity/patient.go b/patientapp/service/entity/patient.go new file mode 100644 index 00000000..e508a8d4 --- /dev/null +++ b/patientapp/service/entity/patient.go @@ -0,0 +1,64 @@ +package entity + +import "time" + +type Patient struct { + ID int64 + FirstName string + LastName string + DateOfBirth *time.Time + Sex Sex + Phone string + Address Address + CaseStatus CaseStatus + ReferralSource ReferralSource + AssignedStaffId int64 + StartDate string + EndDate string +} + +// Sex ================================== Sex type ========================================== +type Sex string + +const ( + SexUnknown Sex = "unknown" + SexMale Sex = "male" + SexFemale Sex = "female" + SexOther Sex = "other" +) + +func (s Sex) SexValidation() bool { + switch s { + case SexUnknown, SexMale, SexFemale, SexOther: + return true + default: + return false + } +} + +// CaseStatus =================================== Case Status ======================================= +type CaseStatus string + +const ( + Open CaseStatus = "open" + Close CaseStatus = "close" + InProgress CaseStatus = "inProgress" +) + +func (s CaseStatus) CaseStatusValidation() bool { + switch s { + case Open, Close, InProgress: + return true + default: + return false + } +} + +// ReferralSource =================================== Referral source ======================================= +type ReferralSource string + +const ( + Hospital ReferralSource = "hospital" + Community ReferralSource = "community" + Other ReferralSource = "other" +) diff --git a/patientapp/tree.txt b/patientapp/tree.txt new file mode 100644 index 0000000000000000000000000000000000000000..d7d81fdbccf6e24c96c4bda0df0727e6050cc390 GIT binary patch literal 10 OcmezWkC%aq0fYe)+5#*9 literal 0 HcmV?d00001