diff --git a/README.md b/README.md index fef07d9..4aeab4e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ go-ohif-proxy/ └── README.md # Project documentation ``` +## 2c. Alur Kerja Kode +Secara umum kode di repo ini kurang lebih memiliki alur: +``` +Routes --> Handlers --> Service --> Repository +``` + ## 3. Instalasi dan Penggunaan ### Prasyarat diff --git a/internal/api/handlers/register.go b/internal/api/handlers/register.go new file mode 100644 index 0000000..19e74ad --- /dev/null +++ b/internal/api/handlers/register.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service" + "go.uber.org/zap" +) + +// RegisterHandler handles user registration requests +type RegisterHandler struct { + logger *zap.Logger + registerService *service.RegisterService +} + +// NewRegisterHandler creates a new registration handler +func NewRegisterHandler(logger *zap.Logger, registerService *service.RegisterService) *RegisterHandler { + return &RegisterHandler{ + logger: logger, + registerService: registerService, + } +} + +// Register handles the creation of new users, patients, and doctors +func (h *RegisterHandler) Register(w http.ResponseWriter, r *http.Request) { + // Parse registration request + var req service.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to parse registration request", zap.Error(err)) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate required fields + if req.Email == "" || req.Password == "" || req.Name == "" || req.Role == "" { + http.Error(w, "Missing required fields", http.StatusBadRequest) + return + } + + // Perform registration + user, err := h.registerService.Register(&req) + if err != nil { + switch err { + case service.ErrEmailExists: + http.Error(w, "Email already exists", http.StatusConflict) + case service.ErrInvalidRole: + http.Error(w, "Invalid user role", http.StatusBadRequest) + case service.ErrInvalidPatient: + http.Error(w, "Invalid patient data", http.StatusBadRequest) + case service.ErrInvalidDoctor: + http.Error(w, "Invalid doctor data", http.StatusBadRequest) + default: + h.logger.Error("Registration failed", zap.Error(err)) + http.Error(w, "Registration failed", http.StatusInternalServerError) + } + return + } + + // Return created user + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + // Don't include password in response + user.Password = "" + + json.NewEncoder(w).Encode(user) +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 415fa82..4a1a689 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -71,7 +71,7 @@ func Auth(authService *service.AuthService, logger *zap.Logger) func(http.Handle // Add user info to request context ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) ctx = context.WithValue(ctx, UserRoleKey, claims.Role) - ctx = context.WithValue(ctx, UserEmailKey, claims.Email) + ctx = context.WithValue(ctx, UserEmailKey, claims.Email) // TODO: Apakah kita perlu param email untuk generate access token? // Store the claims with the defined context key ctx = context.WithValue(ctx, ClaimsKey, claims) diff --git a/internal/api/models/patient.go b/internal/api/models/patient.go index dd628e7..4647c8e 100644 --- a/internal/api/models/patient.go +++ b/internal/api/models/patient.go @@ -4,6 +4,7 @@ package models type PatientDetails struct { PatientID string `json:"patient_id"` PatientName string `json:"patient_name"` + DateOfBirth string `json:"date_of_birth"` // YYYY-MM-DD format StudyInstanceUIDs []string `json:"study_instance_uids,omitempty"` AccessionNumbers []string `json:"accession_numbers,omitempty"` } diff --git a/internal/api/repository/doctor.go b/internal/api/repository/doctor.go index b689b91..3b7ca8b 100644 --- a/internal/api/repository/doctor.go +++ b/internal/api/repository/doctor.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" ) @@ -42,3 +43,16 @@ func (r *DoctorRepository) GetDoctorDetailsByUserID(userID string) (*DBDoctor, e return &dbDoctor, nil } + +// CreateDoctor creates a new doctor record +func (r *DoctorRepository) CreateDoctor(doctorDetails *models.DoctorDetails, userID string) error { + query := `INSERT INTO doctor (Doctor_UsersID, DoctorID, DoctorName, DoctorCreatedAt, DoctorLastUpdatedAt) + VALUES (?, ?, ?, NOW(), NOW())` + + _, err := database.DB.Exec(query, userID, doctorDetails.DoctorID, doctorDetails.DoctorName) + if err != nil { + return fmt.Errorf("database error creating doctor: %w", err) + } + + return nil +} diff --git a/internal/api/repository/patient.go b/internal/api/repository/patient.go index bfccd82..4b53e39 100644 --- a/internal/api/repository/patient.go +++ b/internal/api/repository/patient.go @@ -60,3 +60,22 @@ func (r *PatientRepository) GetPatientDetailsByUserID(userID string) (*models.Pa AccessionNumbers: accessionNumbers, }, nil } + +// CreatePatient creates a new patient record +func (r *PatientRepository) CreatePatient(patientRecord *models.PatientDetails, userID string) error { + // Parse DOB to time.Time + dob, err := time.Parse("2006-01-02", patientRecord.DateOfBirth) + if err != nil { + return fmt.Errorf("invalid date of birth format: %w", err) + } + + query := `INSERT INTO patient (Patient_UsersID, PatientMedrec, PatientName, PatientDoB, PatientCreatedAt, PatientUpdatedAt) + VALUES (?, ?, ?, ?, NOW(), NOW())` + + _, err = database.DB.Exec(query, userID, patientRecord.PatientID, patientRecord.PatientName, dob) + if err != nil { + return fmt.Errorf("database error creating patient: %w", err) + } + + return nil +} diff --git a/internal/api/repository/shortlink.go b/internal/api/repository/shortlink.go index 633b116..fec5449 100644 --- a/internal/api/repository/shortlink.go +++ b/internal/api/repository/shortlink.go @@ -13,7 +13,7 @@ import ( // DBShortLink represents a shortlink from the database type DBShortLink struct { ShortlinkID int `db:"ShortlinkID"` - ShortlinKCode string `db:"ShortlinKCode"` + ShortlinkCode string `db:"ShortlinkCode"` Shortlink_PatientID string `db:"Shortlink_PatientID"` Shortlink_Study_IUID string `db:"Shortlink_Study_IUID"` ShortlinkHashDoB string `db:"ShortlinkHashDoB"` @@ -40,7 +40,7 @@ func NewShortLinkRepository() *ShortLinkRepository { func (s *DBShortLink) ToShortLink() *models.ShortLink { return &models.ShortLink{ ID: fmt.Sprintf("%d", s.ShortlinkID), - Token: s.ShortlinKCode, + Token: s.ShortlinkCode, PatientID: s.Shortlink_PatientID, StudyUID: s.Shortlink_Study_IUID, HashedDOB: s.ShortlinkHashDoB, @@ -56,7 +56,7 @@ func (s *DBShortLink) ToShortLink() *models.ShortLink { func (r *ShortLinkRepository) GetShortLinkByToken(token string) (*models.ShortLink, error) { var dbShortLink DBShortLink - query := `SELECT * FROM shortlink WHERE ShortlinKCode = ?` + query := `SELECT * FROM shortlink WHERE ShortlinkCode = ?` err := database.DB.Get(&dbShortLink, query, token) if err != nil { @@ -72,7 +72,7 @@ func (r *ShortLinkRepository) GetShortLinkByToken(token string) (*models.ShortLi // CreateShortLink stores a new shortlink in the database func (r *ShortLinkRepository) CreateShortLink(shortLink *models.ShortLink) error { query := `INSERT INTO shortlink ( - ShortlinKCode, + ShortlinkCode, Shortlink_PatientID, Shortlink_Study_IUID, ShortlinkHashDoB, @@ -118,7 +118,7 @@ func (r *ShortLinkRepository) UpdateShortLink(shortLink *models.ShortLink) error ShortlinkIsRevoked = ?, ShortlinkRemainingTries = ?, ShortlinkExpiredAt = ? - WHERE ShortlinKCode = ?` + WHERE ShortlinkCode = ?` expiresAt, err := time.Parse(time.RFC3339, shortLink.ExpiresAt) if err != nil { diff --git a/internal/api/repository/study.go b/internal/api/repository/study.go index cfc10d8..bb88e28 100644 --- a/internal/api/repository/study.go +++ b/internal/api/repository/study.go @@ -4,18 +4,19 @@ import ( "fmt" "time" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" ) // DBStudy represents a study from the database type DBStudy struct { - ID int `db:"id"` - Study_PatientID string `db:"Study_PatientID"` - Study_IUID string `db:"Study_IUID"` - Study_AccessionNumber string `db:"Study_AccessionNumber"` - StudyDate time.Time `db:"StudyDate"` - StudyCreatedAt time.Time `db:"StudyCreatedAt"` - StudyUpdatedAt time.Time `db:"StudyUpdatedAt"` + ID int `db:"id"` + Study_PatientID string `db:"Study_PatientID"` + StudyIUID string `db:"StudyIUID"` + StudyAccessionNumber string `db:"StudyAccessionNumber"` + StudyDate time.Time `db:"StudyDate"` + StudyCreatedAt time.Time `db:"StudyCreatedAt"` + StudyUpdatedAt time.Time `db:"StudyUpdatedAt"` } // StudyRepository handles database operations related to studies @@ -45,9 +46,9 @@ func (r *StudyRepository) GetPatientStudies(patientID string) ([]string, []strin var accessionNumbers []string for _, study := range studies { - studyUIDs = append(studyUIDs, study.Study_IUID) - if study.Study_AccessionNumber != "" { - accessionNumbers = append(accessionNumbers, study.Study_AccessionNumber) + studyUIDs = append(studyUIDs, study.StudyIUID) + if study.StudyAccessionNumber != "" { + accessionNumbers = append(accessionNumbers, study.StudyAccessionNumber) } } @@ -58,7 +59,7 @@ func (r *StudyRepository) GetPatientStudies(patientID string) ([]string, []strin func (r *StudyRepository) GetStudyByUID(studyUID string) (*DBStudy, error) { var study DBStudy - query := `SELECT * FROM study WHERE Study_IUID = ?` + query := `SELECT * FROM study WHERE StudyIUID = ?` err := database.DB.Get(&study, query, studyUID) if err != nil { @@ -67,3 +68,32 @@ func (r *StudyRepository) GetStudyByUID(studyUID string) (*DBStudy, error) { return &study, nil } + +// CreateStudy creates a new study record for a patient +func (r *StudyRepository) CreateStudy(patientID string, study models.Study) error { + // Parse study date if provided + var studyDate *time.Time + if study.StudyDate != "" { + parsedTime, err := time.Parse("2006-01-02", study.StudyDate) + if err != nil { + return fmt.Errorf("invalid study date format: %w", err) + } + studyDate = &parsedTime + } + + query := `INSERT INTO study + (Study_PatientID, StudyIUID, StudyAccessionNumber, StudyDate, StudyCreatedAt, StudyUpdatedAt) + VALUES (?, ?, ?, ?, NOW(), NOW())` + + _, err := database.DB.Exec(query, + patientID, + study.StudyInstanceUID, + study.AccessionNumber, + studyDate) + + if err != nil { + return fmt.Errorf("database error creating study: %w", err) + } + + return nil +} diff --git a/internal/api/repository/user.go b/internal/api/repository/user.go index 536c13c..89047eb 100644 --- a/internal/api/repository/user.go +++ b/internal/api/repository/user.go @@ -7,6 +7,7 @@ import ( "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" + "golang.org/x/crypto/bcrypt" ) // DBUser represents a user from the database @@ -138,3 +139,30 @@ func (r *UserRepository) RevokeRefreshToken(token string) error { return nil } + +// CreateUser creates a new user +func (r *UserRepository) CreateUser(user *models.User) error { + // Hash the password before storing + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + query := `INSERT INTO user (UserEmail, UserPassword, UserRole, UserName, UserCreatedAt, UserUpdatedAt) + VALUES (?, ?, ?, ?, NOW(), NOW())` + + result, err := database.DB.Exec(query, user.Email, string(hashedPassword), user.Role, user.Name) + if err != nil { + return fmt.Errorf("database error creating user: %w", err) + } + + // Get the last inserted ID + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + + // Update the user ID + user.ID = fmt.Sprintf("%d", id) + return nil +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 5fc6b51..3a8fb79 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -88,6 +88,11 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { r.Post("/refresh", authHandler.RefreshToken) r.Post("/logout", authHandler.Logout) + // Registration endpoint + registerService := service.NewRegisterService() + registerHandler := handlers.NewRegisterHandler(logger, registerService) + r.Post("/register", registerHandler.Register) + // ShortLink authentication - no auth required shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService) r.Post("/shortlink", shortLinkHandler.ShortLinkAuth) diff --git a/internal/api/service/auth_service.go b/internal/api/service/auth_service.go index dc1f7a5..f8b0ff0 100644 --- a/internal/api/service/auth_service.go +++ b/internal/api/service/auth_service.go @@ -43,7 +43,7 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro } if user == nil { - return nil, ErrInvalidCredentials + return nil, ErrUserNotFound } // Verify password @@ -104,6 +104,7 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro redirectURL = "/" } + // TODO: Apakah kita perlu param email untuk generate access token? // Generate tokens accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, user.Email, user.Role, user.Name, additionalClaims) if err != nil { diff --git a/internal/api/service/register_service.go b/internal/api/service/register_service.go new file mode 100644 index 0000000..ee7d2ae --- /dev/null +++ b/internal/api/service/register_service.go @@ -0,0 +1,117 @@ +package service + +import ( + "database/sql" + "errors" + "fmt" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/repository" +) + +var ( + ErrEmailExists = errors.New("email already exists") + ErrUserNotCreated = errors.New("failed to create user") + ErrInvalidRole = errors.New("invalid user role") + ErrInvalidPatient = errors.New("invalid patient data") + ErrInvalidDoctor = errors.New("invalid doctor data") +) + +// RegisterRequest represents a user registration request +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Role string `json:"role"` // "patient", "ref_doctor", "expertise_doctor", or "admin" + Patient *models.PatientDetails `json:"patient,omitempty"` + Doctor *models.DoctorDetails `json:"doctor,omitempty"` + Studies []models.Study `json:"studies,omitempty"` // Study records for patient +} + +// RegisterService handles user registration +type RegisterService struct { + userRepo *repository.UserRepository + patientRepo *repository.PatientRepository + doctorRepo *repository.DoctorRepository + studyRepo *repository.StudyRepository +} + +// NewRegisterService creates a new register service +func NewRegisterService() *RegisterService { + return &RegisterService{ + userRepo: repository.NewUserRepository(), + patientRepo: repository.NewPatientRepository(), + doctorRepo: repository.NewDoctorRepository(), + studyRepo: repository.NewStudyRepository(), + } +} + +// Register creates a new user with their associated role-specific data +func (s *RegisterService) Register(req *RegisterRequest) (*models.User, error) { + // Validate role + if req.Role != "patient" && req.Role != "ref_doctor" && req.Role != "expertise_doctor" && req.Role != "admin" { + return nil, ErrInvalidRole + } + + // Check role-specific data + if req.Role == "patient" && (req.Patient == nil || req.Patient.PatientID == "" || req.Patient.DateOfBirth == "") { + return nil, ErrInvalidPatient + } + + if (req.Role == "ref_doctor" || req.Role == "expertise_doctor") && (req.Doctor == nil || req.Doctor.DoctorID == "") { + return nil, ErrInvalidDoctor + } + + // Check if email already exists + existingUser, err := s.userRepo.GetUserByEmail(req.Email) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("error checking existing user: %w", err) + } + + if existingUser != nil { + return nil, ErrEmailExists + } + + // Create user + newUser := &models.User{ + Email: req.Email, + Password: req.Password, // Will be hashed in repository + Role: req.Role, + Name: req.Name, + } + + if err := s.userRepo.CreateUser(newUser); err != nil { + return nil, fmt.Errorf("error creating user: %w", err) + } + + // Create role-specific data + if req.Role == "patient" { + err = s.patientRepo.CreatePatient(req.Patient, newUser.ID) + if err != nil { + // TODO: Consider rollback user creation if this fails + return nil, fmt.Errorf("error creating patient: %w", err) + } + + // Create associated study records if provided + if len(req.Studies) > 0 { + for _, study := range req.Studies { + if study.StudyInstanceUID == "" { + continue // Skip studies without UIDs + } + + err = s.studyRepo.CreateStudy(req.Patient.PatientID, study) + if err != nil { + return nil, fmt.Errorf("error creating study for patient: %w", err) + } + } + } + } else if req.Role == "ref_doctor" || req.Role == "expertise_doctor" { + err = s.doctorRepo.CreateDoctor(req.Doctor, newUser.ID) + if err != nil { + // TODO: Consider rollback user creation if this fails + return nil, fmt.Errorf("error creating doctor: %w", err) + } + } + + return newUser, nil +} diff --git a/test/http/ohif-flow.http b/test/http/ohif-flow.http index 045d47c..155eb10 100644 --- a/test/http/ohif-flow.http +++ b/test/http/ohif-flow.http @@ -7,8 +7,8 @@ POST {{baseUrl}}/auth/login Content-Type: application/json { - "email": "patient", - "password": "patient" + "email": "doctor@example.com", + "password": "password123" } ### Login Admin / Exp_doctor (ALL ACCESS) diff --git a/test/http/register-flow.http b/test/http/register-flow.http new file mode 100644 index 0000000..f8025b6 --- /dev/null +++ b/test/http/register-flow.http @@ -0,0 +1,151 @@ +### Register new user, patient, and doctor tests + +# Base URL for the API +@baseUrl = http://localhost:5555 + + +### Register a new patient with multiple studies +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "email": "patient1@example.com", + "password": "password123", + "name": "DIDIT SUYATNA^R.10049.18", + "role": "patient", + "patient": { + "patient_id": "00211622", + "patient_name": "DIDIT SUYATNA^R.10049.18", + "date_of_birth": "1980-01-01" + }, + "studies": [ + { + "study_instance_uid": "1.2.826.0.1.3680043.9.7307.1.20180530066", + "accession_number": "CR.180530.066", + "study_date": "2018-05-30", + "study_description": "Chest X-Ray" + }, + { + "study_instance_uid": "1.2.826.0.1.3680043.9.7307.1.20180713036", + "accession_number": "CR.180713.036", + "study_date": "2018-07-13", + "study_description": "Follow-up X-Ray" + } + ] +} + +### Register another patient with one study +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "email": "patient2@example.com", + "password": "password123", + "name": "Bobon Santoso", + "role": "patient", + "patient": { + "patient_id": "MR00000359", + "patient_name": "Bobon Santoso", + "date_of_birth": "1985-01-01" + }, + "studies": [ + { + "study_instance_uid": "1.2.826.0.1.3680043.9.7307.1.202503196393.01", + "accession_number": "CR.250319.6393.01", + "study_date": "2025-03-19", + "study_description": "MRI Scan" + } + ] +} + +### Register another referring doctor +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "email": "doctor@example.com", + "password": "password123", + "name": "DR. HERWINDO RIDWAN, SP.OT", + "role": "ref_doctor", + "doctor": { + "doctor_id": "DOC002", + "doctor_name": "DR. HERWINDO RIDWAN, SP.OT" + } +} + +### Register a new expertise doctor +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "email": "expert@example.com", + "password": "expert123", + "name": "Dr. Expert Johnson", + "role": "expertise_doctor", + "doctor": { + "doctor_id": "EX456", + "doctor_name": "Dr. Expert Johnson" + } +} + +### Register another referring doctor +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "email": "doctor2@example.com", + "password": "password123", + "name": "Referring^Physician", + "role": "ref_doctor", + "doctor": { + "doctor_id": "DOC003", + "doctor_name": "Referring^Physician" + } +} + +### Register an admin user +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "email": "admin@example.com", + "password": "admin123", + "name": "Admin User", + "role": "admin" +} + +### Login with registered user +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "patient1@example.com", + "password": "password123" +} + +### Login with admin user +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "admin@example.com", + "password": "password123" +} + +### Login with patient user +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "patient2@example.com", + "password": "password123" +} + +### Login with doctor user +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "doctor@example.com", + "password": "password123" +} \ No newline at end of file diff --git a/test/http/shortlink-flow.http b/test/http/shortlink-flow.http index 3ed58c6..76f976f 100644 --- a/test/http/shortlink-flow.http +++ b/test/http/shortlink-flow.http @@ -7,8 +7,8 @@ POST http://localhost:5555/auth/login Content-Type: application/json { - "email": "admin", - "password": "admin" + "email": "patient1@example.com", + "password": "password123" } diff --git a/test/http/test.http b/test/http/test.http index aa12147..a2320ae 100644 --- a/test/http/test.http +++ b/test/http/test.http @@ -15,8 +15,8 @@ POST {{baseUrl}}/auth/login Content-Type: application/json { - "email": "patient", - "password": "patient" + "email": "patient1@example.com", + "password": "password123" } ### Refresh TOken @@ -24,7 +24,7 @@ POST {{baseUrl}}/auth/refresh Content-Type: application/json { - "refresh" + "refresh_token": {{ refresh_token}} } ### 2. QIDO-RS: Search for Studies