diff --git a/.gitignore b/.gitignore index 3624eb1..ac60a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env frontend/node_modules frontend/coverage -backend/coverage.out \ No newline at end of file +backend/coverage.out +docs \ No newline at end of file diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 506e51e..1d45e56 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -73,6 +73,7 @@ func main() { appContainer.GeocodeWorker.Start(1) appContainer.LeaveScheduler.Start() appContainer.NotificationScheduler.Start() + appContainer.SubscriptionScheduler.Start() go appContainer.WebsocketHub.Run() logger.Info("Starting BaseKarya API Server...") diff --git a/backend/internal/bootstrap/container.go b/backend/internal/bootstrap/container.go index ad1ac2c..871568d 100644 --- a/backend/internal/bootstrap/container.go +++ b/backend/internal/bootstrap/container.go @@ -68,7 +68,8 @@ type Container struct { GeocodeWorker attendance.GeocodeWorker LeaveScheduler leave.Scheduler NotificationScheduler notification.Scheduler - ContractScheduler contract.Scheduler + ContractScheduler contract.Scheduler + SubscriptionScheduler subscription.Scheduler } func NewContainer() (*Container, error) { @@ -108,7 +109,8 @@ func NewContainer() (*Container, error) { financeRepo := finance.NewRepository(db.GetDB()) astRepo := asset.NewRepository(db.GetDB()) subscriptionRepo := subscription.NewRepository(db.GetDB()) - subscriptionMW := middleware.NewSubscriptionMiddleware(db.GetDB()) + planCache := subscription.NewPlanCacheService(db.GetDB(), redis) + subscriptionMW := middleware.NewSubscriptionMiddleware(planCache) healthSvc := health.NewService(healthRepo) notificationSvc := notification.NewService(wsHub, notificationRepo) @@ -130,7 +132,7 @@ func NewContainer() (*Container, error) { recruitmentSvc := recruitment.NewService(recruitmentRepo, storage, notificationSvc, userRepo, onboardingSvc, transactionManager) astSvc := asset.NewService(astRepo, notificationSvc, userRepo, transactionManager, excel) financeSvc := finance.NewService(financeRepo, notificationSvc, userRepo, transactionManager, excel) - subscriptionSvc := subscription.NewService(subscriptionRepo, companyRepo, rbacRepo, userRepo, redis) + subscriptionSvc := subscription.NewService(subscriptionRepo, companyRepo, rbacRepo, userRepo, planCache) healthHandler := health.NewHandler(healthSvc) authHandler := auth.NewHandler(authSvc) @@ -161,6 +163,7 @@ func NewContainer() (*Container, error) { leaveScheduler := leave.NewScheduler(cronScheduler, leaveSvc) notificationScheduler := notification.NewScheduler(cronScheduler, notificationSvc) contractScheduler := contract.NewScheduler(cronScheduler, contractSvc) + subscriptionScheduler := subscription.NewScheduler(cronScheduler, subscriptionRepo, planCache) return &Container{ Config: cfg, @@ -203,7 +206,8 @@ func NewContainer() (*Container, error) { GeocodeWorker: geocodeWorker, LeaveScheduler: leaveScheduler, NotificationScheduler: notificationScheduler, - ContractScheduler: contractScheduler, + ContractScheduler: contractScheduler, + SubscriptionScheduler: subscriptionScheduler, }, nil } @@ -229,6 +233,10 @@ func (c *Container) Close() error { c.ContractScheduler.Stop() } + if c.SubscriptionScheduler != nil { + c.SubscriptionScheduler.Stop() + } + if c.Redis != nil { c.Redis.Close() } diff --git a/backend/internal/middleware/subscription.go b/backend/internal/middleware/subscription.go index 9a9aa25..428cf35 100644 --- a/backend/internal/middleware/subscription.go +++ b/backend/internal/middleware/subscription.go @@ -1,26 +1,26 @@ package middleware import ( - "basekarya-backend/pkg/response" - "basekarya-backend/pkg/utils" "context" - "encoding/json" "net/http" + "basekarya-backend/pkg/response" + "basekarya-backend/pkg/utils" + "github.com/labstack/echo/v4" - "gorm.io/gorm" ) -type SubscriptionMiddleware struct { - db *gorm.DB +type ModuleAccessProvider interface { + HasAccess(ctx context.Context, companyID uint, module string) (bool, error) + CheckEmployeeLimit(ctx context.Context) (bool, error) } -func NewSubscriptionMiddleware(db *gorm.DB) *SubscriptionMiddleware { - return &SubscriptionMiddleware{db: db} +type SubscriptionMiddleware struct { + planCache ModuleAccessProvider } -type planFeatures struct { - Modules []string `json:"modules"` +func NewSubscriptionMiddleware(planCache ModuleAccessProvider) *SubscriptionMiddleware { + return &SubscriptionMiddleware{planCache: planCache} } func (m *SubscriptionMiddleware) RequireModule(moduleName string) echo.MiddlewareFunc { @@ -35,58 +35,20 @@ func (m *SubscriptionMiddleware) RequireModule(moduleName string) echo.Middlewar return next(ctx) } - var featuresJSON string - err := m.db.Table("subscription_plans"). - Select("subscription_plans.features"). - Joins("JOIN companies ON companies.subscription_plan_id = subscription_plans.id"). - Where("companies.id = ?", companyID). - Scan(&featuresJSON).Error - - if err != nil || featuresJSON == "" { - return response.NewResponses[any](ctx, http.StatusForbidden, "subscription plan not found", nil, nil, nil) - } - - var features planFeatures - if err := json.Unmarshal([]byte(featuresJSON), &features); err != nil { - return response.NewResponses[any](ctx, http.StatusForbidden, "failed to parse subscription features", nil, nil, nil) - } + hasAccess, err := m.planCache.HasAccess(ctx.Request().Context(), companyID, moduleName) + if err != nil { + return response.NewResponses[any](ctx, http.StatusForbidden, "subscription plan not found", nil, nil, nil) + } - for _, mod := range features.Modules { - if mod == moduleName { - return next(ctx) - } + if !hasAccess { + return response.NewResponses[any](ctx, http.StatusForbidden, "Module not available in your subscription plan", nil, nil, nil) } - return response.NewResponses[any](ctx, http.StatusForbidden, "Module not available in your subscription plan", nil, nil, nil) + return next(ctx) } } } func (m *SubscriptionMiddleware) CheckEmployeeLimit(ctx context.Context) (bool, error) { - companyID := utils.GetCompanyIDFromCtx(ctx) - if companyID == 0 { - return true, nil - } - - var maxEmployees int - err := m.db.Table("subscription_plans"). - Select("subscription_plans.max_employees"). - Joins("JOIN companies ON companies.subscription_plan_id = subscription_plans.id"). - Where("companies.id = ?", companyID). - Scan(&maxEmployees).Error - if err != nil { - return true, err - } - - if maxEmployees == 0 { - return true, nil - } - - var count int64 - m.db.Table("users"). - Joins("JOIN roles ON roles.id = users.role_id"). - Where("users.company_id = ? AND roles.name = ? AND users.is_active = ?", companyID, "EMPLOYEE", true). - Count(&count) - - return count < int64(maxEmployees), nil + return m.planCache.CheckEmployeeLimit(ctx) } diff --git a/backend/internal/middleware/subscription_test.go b/backend/internal/middleware/subscription_test.go index f6ab019..abc4c8f 100644 --- a/backend/internal/middleware/subscription_test.go +++ b/backend/internal/middleware/subscription_test.go @@ -2,12 +2,11 @@ package middleware import ( "context" + "errors" "net/http" "testing" "basekarya-backend/internal/infrastructure" - "basekarya-backend/internal/modules/company" - "basekarya-backend/internal/modules/subscription" "basekarya-backend/internal/testutil" "basekarya-backend/pkg/constants" @@ -16,17 +15,28 @@ import ( "github.com/stretchr/testify/require" ) -func newSubscriptionTestDB(t *testing.T) *testutil.TestDB { - t.Helper() - tdb := testutil.NewTestDB(&subscription.SubscriptionPlan{}, &company.Company{}) - return tdb +type mockPlanCache struct { + hasAccess func(ctx context.Context, companyID uint, module string) (bool, error) + checkEmpLimit func(ctx context.Context) (bool, error) } -func TestSubscriptionMiddleware_RequireModule_PlatformAdminPasses(t *testing.T) { - tdb := newSubscriptionTestDB(t) - defer tdb.Close() +func (m *mockPlanCache) HasAccess(ctx context.Context, companyID uint, module string) (bool, error) { + if m.hasAccess != nil { + return m.hasAccess(ctx, companyID, module) + } + return false, errors.New("not implemented") +} - mw := NewSubscriptionMiddleware(tdb.DB) +func (m *mockPlanCache) CheckEmployeeLimit(ctx context.Context) (bool, error) { + if m.checkEmpLimit != nil { + return m.checkEmpLimit(ctx) + } + return true, nil +} + +func TestSubscriptionMiddleware_RequireModule_PlatformAdminPasses(t *testing.T) { + mock := &mockPlanCache{} + mw := NewSubscriptionMiddleware(mock) claims := &infrastructure.MyClaims{ UserID: 1, @@ -47,13 +57,12 @@ func TestSubscriptionMiddleware_RequireModule_PlatformAdminPasses(t *testing.T) } func TestSubscriptionMiddleware_RequireModule_CompanyHasModule(t *testing.T) { - tdb := newSubscriptionTestDB(t) - defer tdb.Close() - - tdb.DB.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active, created_at, updated_at) VALUES (1, 'Pro', 'pro', 10, 99.00, '{"modules":["payroll","attendance"]}', 1, datetime('now'), datetime('now'))`) - tdb.DB.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status, created_at, updated_at) VALUES (1, 'TestCo', 1, 'ACTIVE', datetime('now'), datetime('now'))`) - - mw := NewSubscriptionMiddleware(tdb.DB) + mock := &mockPlanCache{ + hasAccess: func(ctx context.Context, companyID uint, module string) (bool, error) { + return true, nil + }, + } + mw := NewSubscriptionMiddleware(mock) at := testutil.NewAPITest(t, http.MethodGet, "/test", nil) @@ -73,13 +82,12 @@ func TestSubscriptionMiddleware_RequireModule_CompanyHasModule(t *testing.T) { } func TestSubscriptionMiddleware_RequireModule_CompanyMissingModule(t *testing.T) { - tdb := newSubscriptionTestDB(t) - defer tdb.Close() - - tdb.DB.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active, created_at, updated_at) VALUES (1, 'Basic', 'basic', 10, 29.00, '{"modules":["attendance"]}', 1, datetime('now'), datetime('now'))`) - tdb.DB.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status, created_at, updated_at) VALUES (1, 'TestCo', 1, 'ACTIVE', datetime('now'), datetime('now'))`) - - mw := NewSubscriptionMiddleware(tdb.DB) + mock := &mockPlanCache{ + hasAccess: func(ctx context.Context, companyID uint, module string) (bool, error) { + return false, nil + }, + } + mw := NewSubscriptionMiddleware(mock) at := testutil.NewAPITest(t, http.MethodGet, "/test", nil) @@ -99,12 +107,12 @@ func TestSubscriptionMiddleware_RequireModule_CompanyMissingModule(t *testing.T) } func TestSubscriptionMiddleware_RequireModule_CompanyNoPlan(t *testing.T) { - tdb := newSubscriptionTestDB(t) - defer tdb.Close() - - tdb.DB.Exec(`INSERT INTO companies (id, name, subscription_status, created_at, updated_at) VALUES (1, 'TestCo', 'ACTIVE', datetime('now'), datetime('now'))`) - - mw := NewSubscriptionMiddleware(tdb.DB) + mock := &mockPlanCache{ + hasAccess: func(ctx context.Context, companyID uint, module string) (bool, error) { + return false, errors.New("subscription plan not found") + }, + } + mw := NewSubscriptionMiddleware(mock) at := testutil.NewAPITest(t, http.MethodGet, "/test", nil) @@ -122,3 +130,16 @@ func TestSubscriptionMiddleware_RequireModule_CompanyNoPlan(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusForbidden, rec.Code) } + +func TestSubscriptionMiddleware_CheckEmployeeLimit(t *testing.T) { + mock := &mockPlanCache{ + checkEmpLimit: func(ctx context.Context) (bool, error) { + return false, nil + }, + } + mw := NewSubscriptionMiddleware(mock) + + allowed, err := mw.CheckEmployeeLimit(context.Background()) + require.NoError(t, err) + assert.False(t, allowed) +} diff --git a/backend/internal/modules/onboarding/dto.go b/backend/internal/modules/onboarding/dto.go index b7ece42..1e74197 100644 --- a/backend/internal/modules/onboarding/dto.go +++ b/backend/internal/modules/onboarding/dto.go @@ -2,53 +2,25 @@ package onboarding import "time" -// ── Template DTOs ───────────────────────────────────────────────────────────── - -type TemplateItemRequest struct { - TaskName string `json:"task_name" validate:"required"` - Description string `json:"description"` - SortOrder int `json:"sort_order"` -} - -type CreateTemplateRequest struct { - Name string `json:"name" validate:"required"` - Department string `json:"department" validate:"required"` - Items []TemplateItemRequest `json:"items"` -} +// ── Workflow DTOs ───────────────────────────────────────────────────────────── -type UpdateTemplateRequest struct { - Name string `json:"name" validate:"required"` - Department string `json:"department" validate:"required"` - Items []TemplateItemRequest `json:"items"` +type CreateWorkflowRequest struct { + ApplicantID *uint `json:"applicant_id"` + EmployeeID *uint `json:"employee_id"` + NewHireName string `json:"new_hire_name" validate:"required"` + NewHireEmail string `json:"new_hire_email" validate:"required,email"` + Position string `json:"position"` + Department string `json:"department"` + StartDate string `json:"start_date"` // YYYY-MM-DD + Tasks []WorkflowTaskRequest `json:"tasks"` } -type TemplateItemResponse struct { - ID uint `json:"id"` - TaskName string `json:"task_name"` +type WorkflowTaskRequest struct { + TaskName string `json:"task_name" validate:"required"` Description string `json:"description"` SortOrder int `json:"sort_order"` } -type TemplateResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Department string `json:"department"` - Items []TemplateItemResponse `json:"items"` - CreatedAt time.Time `json:"created_at"` -} - -// ── Workflow DTOs ───────────────────────────────────────────────────────────── - -type CreateWorkflowRequest struct { - ApplicantID *uint `json:"applicant_id"` - EmployeeID *uint `json:"employee_id"` - NewHireName string `json:"new_hire_name" validate:"required"` - NewHireEmail string `json:"new_hire_email" validate:"required,email"` - Position string `json:"position"` - Department string `json:"department"` - StartDate string `json:"start_date"` // YYYY-MM-DD -} - type WorkflowFilter struct { Status string Search string @@ -60,7 +32,6 @@ type TaskResponse struct { ID uint `json:"id"` TaskName string `json:"task_name"` Description string `json:"description"` - Department string `json:"department"` IsCompleted bool `json:"is_completed"` CompletedBy string `json:"completed_by,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` @@ -91,9 +62,13 @@ type WorkflowDetailResponse struct { Progress int `json:"progress"` WelcomeEmailSent bool `json:"welcome_email_sent"` CreatedAt time.Time `json:"created_at"` - ITTasks []TaskResponse `json:"it_tasks"` - HRTasks []TaskResponse `json:"hr_tasks"` - OtherTasks []TaskResponse `json:"other_tasks"` + Tasks []TaskResponse `json:"tasks"` +} + +// ── Task Update ─────────────────────────────────────────────────────────────── + +type UpdateWorkflowTasksRequest struct { + Tasks []WorkflowTaskRequest `json:"tasks" validate:"required,min=1"` } // ── Task Complete ───────────────────────────────────────────────────────────── diff --git a/backend/internal/modules/onboarding/entity.go b/backend/internal/modules/onboarding/entity.go index 68aa782..9966180 100644 --- a/backend/internal/modules/onboarding/entity.go +++ b/backend/internal/modules/onboarding/entity.go @@ -4,37 +4,8 @@ import ( "time" "basekarya-backend/internal/modules/user" - - "gorm.io/gorm" ) -// OnboardingTemplate is a reusable checklist template (e.g. "IT Setup", "HR Document Collection"). -type OnboardingTemplate struct { - ID uint `gorm:"primaryKey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - Name string `gorm:"type:varchar(100);not null" json:"name"` - Department string `gorm:"type:varchar(50);not null" json:"department"` - CompanyID uint `gorm:"index;not null" json:"company_id"` - - Items []OnboardingTemplateItem `gorm:"foreignKey:TemplateID" json:"items,omitempty"` -} - -func (OnboardingTemplate) TableName() string { return "onboarding_templates" } - -// OnboardingTemplateItem is a single task inside a template. -type OnboardingTemplateItem struct { - ID uint `gorm:"primaryKey" json:"id"` - TemplateID uint `gorm:"not null" json:"template_id"` - CompanyID uint `gorm:"index;not null" json:"company_id"` - TaskName string `gorm:"type:varchar(255);not null" json:"task_name"` - Description string `gorm:"type:text" json:"description"` - SortOrder int `gorm:"default:0" json:"sort_order"` -} - -func (OnboardingTemplateItem) TableName() string { return "onboarding_template_items" } - // OnboardingWorkflow is a per-hire instance of the onboarding process. type OnboardingWorkflow struct { ID uint `gorm:"primaryKey" json:"id"` @@ -62,12 +33,10 @@ func (OnboardingWorkflow) TableName() string { return "onboarding_workflows" } type OnboardingTask struct { ID uint `gorm:"primaryKey" json:"id"` OnboardingWorkflowID uint `gorm:"not null" json:"onboarding_workflow_id"` - TemplateItemID *uint `json:"template_item_id"` CompanyID uint `gorm:"index;not null" json:"company_id"` TaskName string `gorm:"type:varchar(255);not null" json:"task_name"` Description string `gorm:"type:text" json:"description"` - Department string `gorm:"type:varchar(50);not null" json:"department"` IsCompleted bool `gorm:"default:false" json:"is_completed"` CompletedBy *uint `json:"completed_by"` CompletedAt *time.Time `json:"completed_at"` @@ -86,9 +55,3 @@ const ( WorkflowStatusCompleted = "COMPLETED" ) -// DeletedAt is not on workflows/tasks; we use hard deletes only via cascade. -// Soft-delete is only on the template level. -type OnboardingTemplateWithDeleted struct { - OnboardingTemplate - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} diff --git a/backend/internal/modules/onboarding/handler.go b/backend/internal/modules/onboarding/handler.go index 657b137..b3111cc 100644 --- a/backend/internal/modules/onboarding/handler.go +++ b/backend/internal/modules/onboarding/handler.go @@ -19,71 +19,6 @@ func NewHandler(service Service) *Handler { return &Handler{service} } -// ── Template Handlers ───────────────────────────────────────────────────────── - -func (h *Handler) CreateTemplate(ctx echo.Context) error { - var req CreateTemplateRequest - if err := ctx.Bind(&req); err != nil { - return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid Request", nil, err, nil) - } - if err := ctx.Validate(&req); err != nil { - return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid Request", nil, err, nil) - } - - if err := h.service.CreateTemplate(ctx.Request().Context(), &req); err != nil { - logger.Errorw("CreateTemplate failed:", err) - return response.NewResponses[any](ctx, http.StatusInternalServerError, err.Error(), nil, err, nil) - } - - return response.NewResponses[any](ctx, http.StatusCreated, "Template created", nil, nil, nil) -} - -func (h *Handler) GetTemplates(ctx echo.Context) error { - data, err := h.service.GetTemplates(ctx.Request().Context()) - if err != nil { - logger.Errorw("GetTemplates failed:", err) - return response.NewResponses[any](ctx, http.StatusInternalServerError, err.Error(), nil, err, nil) - } - - return response.NewResponses[any](ctx, http.StatusOK, "Get Templates Success", data, nil, nil) -} - -func (h *Handler) UpdateTemplate(ctx echo.Context) error { - id, err := strconv.Atoi(ctx.Param("id")) - if err != nil { - return response.NewResponses[any](ctx, http.StatusBadRequest, "invalid id", nil, err, nil) - } - - var req UpdateTemplateRequest - if err := ctx.Bind(&req); err != nil { - return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid Request", nil, err, nil) - } - if err := ctx.Validate(&req); err != nil { - return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid Request", nil, err, nil) - } - - if err := h.service.UpdateTemplate(ctx.Request().Context(), uint(id), &req); err != nil { - logger.Errorw("UpdateTemplate failed:", err) - return response.NewResponses[any](ctx, http.StatusBadRequest, err.Error(), nil, err, nil) - } - - return response.NewResponses[any](ctx, http.StatusOK, "Template updated", nil, nil, nil) -} - -func (h *Handler) DeleteTemplate(ctx echo.Context) error { - id, err := strconv.Atoi(ctx.Param("id")) - if err != nil { - return response.NewResponses[any](ctx, http.StatusBadRequest, "invalid id", nil, err, nil) - } - - if err := h.service.DeleteTemplate(ctx.Request().Context(), uint(id)); err != nil { - logger.Errorw("DeleteTemplate failed:", err) - return response.NewResponses[any](ctx, http.StatusBadRequest, err.Error(), nil, err, nil) - } - - return response.NewResponses[any](ctx, http.StatusOK, "Template deleted", nil, nil, nil) -} - // ── Workflow Handlers ───────────────────────────────────────────────────────── func (h *Handler) CreateWorkflow(ctx echo.Context) error { @@ -164,3 +99,25 @@ func (h *Handler) CompleteTask(ctx echo.Context) error { return response.NewResponses[any](ctx, http.StatusOK, "Task completed", nil, nil, nil) } + +func (h *Handler) UpdateWorkflowTasks(ctx echo.Context) error { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil { + return response.NewResponses[any](ctx, http.StatusBadRequest, "invalid workflow id", nil, err, nil) + } + + var req UpdateWorkflowTasksRequest + if err := ctx.Bind(&req); err != nil { + return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid Request", nil, err, nil) + } + if err := ctx.Validate(&req); err != nil { + return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid Request", nil, err, nil) + } + + if err := h.service.UpdateWorkflowTasks(ctx.Request().Context(), uint(id), &req); err != nil { + logger.Errorw("UpdateWorkflowTasks failed:", err) + return response.NewResponses[any](ctx, http.StatusBadRequest, err.Error(), nil, err, nil) + } + + return response.NewResponses[any](ctx, http.StatusOK, "Workflow tasks updated", nil, nil, nil) +} diff --git a/backend/internal/modules/onboarding/handler_test.go b/backend/internal/modules/onboarding/handler_test.go index 8b03b84..ff1ffbe 100644 --- a/backend/internal/modules/onboarding/handler_test.go +++ b/backend/internal/modules/onboarding/handler_test.go @@ -15,217 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestHandler_CreateTemplate(t *testing.T) { - tests := []struct { - name string - body interface{} - setupMocks func(*mockService) - wantStatus int - }{ - { - name: "success", - body: CreateTemplateRequest{ - Name: "IT Setup", - Department: "IT", - Items: []TemplateItemRequest{ - {TaskName: "Create Email", SortOrder: 1}, - }, - }, - setupMocks: func(svc *mockService) { - svc.On("CreateTemplate", mock.Anything, mock.AnythingOfType("*onboarding.CreateTemplateRequest")).Return(nil) - }, - wantStatus: http.StatusCreated, - }, - { - name: "invalid body", - body: map[string]interface{}{"name": ""}, - setupMocks: func(svc *mockService) {}, - wantStatus: http.StatusBadRequest, - }, - { - name: "service error", - body: CreateTemplateRequest{ - Name: "IT Setup", - Department: "IT", - }, - setupMocks: func(svc *mockService) { - svc.On("CreateTemplate", mock.Anything, mock.AnythingOfType("*onboarding.CreateTemplateRequest")).Return(errors.New("db error")) - }, - wantStatus: http.StatusInternalServerError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc := new(mockService) - tt.setupMocks(svc) - handler := NewHandler(svc) - - at := testutil.NewAPITest(t, http.MethodPost, "/api/onboarding/templates", tt.body) - at.WithAuthContext(&infrastructure.MyClaims{UserID: 1, CompanyID: 1}) - - rec, err := at.Execute(handler.CreateTemplate) - require.NoError(t, err) - assert.Equal(t, tt.wantStatus, rec.Code) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - if tt.wantStatus < 400 { - assert.Nil(t, resp["error"]) - } - }) - } -} - -func TestHandler_GetTemplates(t *testing.T) { - tests := []struct { - name string - setupMocks func(*mockService) - wantStatus int - }{ - { - name: "success", - setupMocks: func(svc *mockService) { - svc.On("GetTemplates", mock.Anything).Return([]TemplateResponse{ - {ID: 1, Name: "IT Setup", Department: "IT"}, - }, nil) - }, - wantStatus: http.StatusOK, - }, - { - name: "service error", - setupMocks: func(svc *mockService) { - svc.On("GetTemplates", mock.Anything).Return(nil, errors.New("db error")) - }, - wantStatus: http.StatusInternalServerError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc := new(mockService) - tt.setupMocks(svc) - handler := NewHandler(svc) - - at := testutil.NewAPITest(t, http.MethodGet, "/api/onboarding/templates", nil) - at.WithAuthContext(&infrastructure.MyClaims{UserID: 1, CompanyID: 1}) - - rec, err := at.Execute(handler.GetTemplates) - require.NoError(t, err) - assert.Equal(t, tt.wantStatus, rec.Code) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - if tt.wantStatus < 400 { - assert.Nil(t, resp["error"]) - } - }) - } -} - -func TestHandler_UpdateTemplate(t *testing.T) { - tests := []struct { - name string - pathParams map[string]string - body interface{} - setupMocks func(*mockService) - wantStatus int - }{ - { - name: "success", - pathParams: map[string]string{"id": "1"}, - body: UpdateTemplateRequest{ - Name: "Updated", - Department: "IT", - }, - setupMocks: func(svc *mockService) { - svc.On("UpdateTemplate", mock.Anything, uint(1), mock.AnythingOfType("*onboarding.UpdateTemplateRequest")).Return(nil) - }, - wantStatus: http.StatusOK, - }, - { - name: "invalid id", - pathParams: map[string]string{"id": "abc"}, - body: UpdateTemplateRequest{Name: "Test", Department: "IT"}, - setupMocks: func(svc *mockService) {}, - wantStatus: http.StatusBadRequest, - }, - { - name: "service error", - pathParams: map[string]string{"id": "1"}, - body: UpdateTemplateRequest{Name: "Updated", Department: "IT"}, - setupMocks: func(svc *mockService) { - svc.On("UpdateTemplate", mock.Anything, uint(1), mock.AnythingOfType("*onboarding.UpdateTemplateRequest")).Return(errors.New("not found")) - }, - wantStatus: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc := new(mockService) - tt.setupMocks(svc) - handler := NewHandler(svc) - - at := testutil.NewAPITest(t, http.MethodPut, "/api/onboarding/templates/:id", tt.body) - at.WithPathParams(tt.pathParams) - at.WithAuthContext(&infrastructure.MyClaims{UserID: 1, CompanyID: 1}) - - rec, err := at.Execute(handler.UpdateTemplate) - require.NoError(t, err) - assert.Equal(t, tt.wantStatus, rec.Code) - }) - } -} - -func TestHandler_DeleteTemplate(t *testing.T) { - tests := []struct { - name string - pathParams map[string]string - setupMocks func(*mockService) - wantStatus int - }{ - { - name: "success", - pathParams: map[string]string{"id": "1"}, - setupMocks: func(svc *mockService) { - svc.On("DeleteTemplate", mock.Anything, uint(1)).Return(nil) - }, - wantStatus: http.StatusOK, - }, - { - name: "invalid id", - pathParams: map[string]string{"id": "abc"}, - setupMocks: func(svc *mockService) {}, - wantStatus: http.StatusBadRequest, - }, - { - name: "service error", - pathParams: map[string]string{"id": "1"}, - setupMocks: func(svc *mockService) { - svc.On("DeleteTemplate", mock.Anything, uint(1)).Return(errors.New("not found")) - }, - wantStatus: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc := new(mockService) - tt.setupMocks(svc) - handler := NewHandler(svc) - - at := testutil.NewAPITest(t, http.MethodDelete, "/api/onboarding/templates/:id", nil) - at.WithPathParams(tt.pathParams) - at.WithAuthContext(&infrastructure.MyClaims{UserID: 1, CompanyID: 1}) - - rec, err := at.Execute(handler.DeleteTemplate) - require.NoError(t, err) - assert.Equal(t, tt.wantStatus, rec.Code) - }) - } -} - func TestHandler_CreateWorkflow(t *testing.T) { tests := []struct { name string @@ -439,3 +228,80 @@ func TestHandler_CompleteTask(t *testing.T) { }) } } + +func TestHandler_UpdateWorkflowTasks(t *testing.T) { + tests := []struct { + name string + pathParams map[string]string + body interface{} + setupMocks func(*mockService) + wantStatus int + }{ + { + name: "success", + pathParams: map[string]string{"id": "1"}, + body: UpdateWorkflowTasksRequest{ + Tasks: []WorkflowTaskRequest{ + {TaskName: "Setup Email", SortOrder: 1}, + }, + }, + setupMocks: func(svc *mockService) { + svc.On("UpdateWorkflowTasks", mock.Anything, uint(1), mock.AnythingOfType("*onboarding.UpdateWorkflowTasksRequest")).Return(nil) + }, + wantStatus: http.StatusOK, + }, + { + name: "invalid id", + pathParams: map[string]string{"id": "abc"}, + body: UpdateWorkflowTasksRequest{ + Tasks: []WorkflowTaskRequest{ + {TaskName: "Task", SortOrder: 1}, + }, + }, + setupMocks: func(svc *mockService) {}, + wantStatus: http.StatusBadRequest, + }, + { + name: "empty tasks", + pathParams: map[string]string{"id": "1"}, + body: UpdateWorkflowTasksRequest{Tasks: []WorkflowTaskRequest{}}, + setupMocks: func(svc *mockService) {}, + wantStatus: http.StatusBadRequest, + }, + { + name: "service error", + pathParams: map[string]string{"id": "1"}, + body: UpdateWorkflowTasksRequest{ + Tasks: []WorkflowTaskRequest{ + {TaskName: "Task", SortOrder: 1}, + }, + }, + setupMocks: func(svc *mockService) { + svc.On("UpdateWorkflowTasks", mock.Anything, uint(1), mock.AnythingOfType("*onboarding.UpdateWorkflowTasksRequest")).Return(errors.New("not found")) + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := new(mockService) + tt.setupMocks(svc) + handler := NewHandler(svc) + + at := testutil.NewAPITest(t, http.MethodPut, "/api/onboarding/workflows/:id/tasks", tt.body) + at.WithPathParams(tt.pathParams) + at.WithAuthContext(&infrastructure.MyClaims{UserID: 1, CompanyID: 1}) + + rec, err := at.Execute(handler.UpdateWorkflowTasks) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, rec.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + if tt.wantStatus < 400 { + assert.Nil(t, resp["error"]) + } + }) + } +} diff --git a/backend/internal/modules/onboarding/mocks_test.go b/backend/internal/modules/onboarding/mocks_test.go index 53590e1..3322592 100644 --- a/backend/internal/modules/onboarding/mocks_test.go +++ b/backend/internal/modules/onboarding/mocks_test.go @@ -18,34 +18,6 @@ import ( type mockRepo struct{ mock.Mock } -func (m *mockRepo) CreateTemplate(ctx context.Context, t *OnboardingTemplate) error { - return m.Called(ctx, t).Error(0) -} - -func (m *mockRepo) FindAllTemplates(ctx context.Context) ([]OnboardingTemplate, error) { - args := m.Called(ctx) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]OnboardingTemplate), args.Error(1) -} - -func (m *mockRepo) FindTemplateByID(ctx context.Context, id uint) (*OnboardingTemplate, error) { - args := m.Called(ctx, id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*OnboardingTemplate), args.Error(1) -} - -func (m *mockRepo) UpdateTemplate(ctx context.Context, t *OnboardingTemplate) error { - return m.Called(ctx, t).Error(0) -} - -func (m *mockRepo) DeleteTemplate(ctx context.Context, id uint) error { - return m.Called(ctx, id).Error(0) -} - func (m *mockRepo) CreateWorkflow(ctx context.Context, w *OnboardingWorkflow) error { return m.Called(ctx, w).Error(0) } @@ -97,6 +69,10 @@ func (m *mockRepo) MarkWorkflowCompleted(ctx context.Context, id uint) error { return m.Called(ctx, id).Error(0) } +func (m *mockRepo) DeletePendingTasks(ctx context.Context, workflowID uint) error { + return m.Called(ctx, workflowID).Error(0) +} + type mockNotificationProvider struct{ mock.Mock } func (m *mockNotificationProvider) SendNotification(ctx context.Context, userID uint, Type string, Title string, Message string, relatedID uint) error { @@ -180,34 +156,6 @@ func (m *mockMasterProvider) FindShiftByName(ctx context.Context, name string) ( type mockService struct{ mock.Mock } -func (m *mockService) CreateTemplate(ctx context.Context, req *CreateTemplateRequest) error { - return m.Called(ctx, req).Error(0) -} - -func (m *mockService) GetTemplates(ctx context.Context) ([]TemplateResponse, error) { - args := m.Called(ctx) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]TemplateResponse), args.Error(1) -} - -func (m *mockService) GetTemplateByID(ctx context.Context, id uint) (*TemplateResponse, error) { - args := m.Called(ctx, id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*TemplateResponse), args.Error(1) -} - -func (m *mockService) UpdateTemplate(ctx context.Context, id uint, req *UpdateTemplateRequest) error { - return m.Called(ctx, id, req).Error(0) -} - -func (m *mockService) DeleteTemplate(ctx context.Context, id uint) error { - return m.Called(ctx, id).Error(0) -} - func (m *mockService) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) error { return m.Called(ctx, req).Error(0) } @@ -236,6 +184,10 @@ func (m *mockService) CompleteTask(ctx context.Context, taskID uint, completedBy return m.Called(ctx, taskID, completedByID, req).Error(0) } +func (m *mockService) UpdateWorkflowTasks(ctx context.Context, workflowID uint, req *UpdateWorkflowTasksRequest) error { + return m.Called(ctx, workflowID, req).Error(0) +} + func newTestOnboardingService() (Service, *mockRepo, *mockNotificationProvider, *mockUserProvider, *mockEmailProvider, *mockCompanyProvider, *mockRoleProvider, *mockDepartmentProvider, *mockMasterProvider, *testutil.MockTransactionManager) { repo := new(mockRepo) notif := new(mockNotificationProvider) diff --git a/backend/internal/modules/onboarding/repository.go b/backend/internal/modules/onboarding/repository.go index 2c6a6ee..9566601 100644 --- a/backend/internal/modules/onboarding/repository.go +++ b/backend/internal/modules/onboarding/repository.go @@ -10,13 +10,6 @@ import ( ) type Repository interface { - // Templates - CreateTemplate(ctx context.Context, t *OnboardingTemplate) error - FindAllTemplates(ctx context.Context) ([]OnboardingTemplate, error) - FindTemplateByID(ctx context.Context, id uint) (*OnboardingTemplate, error) - UpdateTemplate(ctx context.Context, t *OnboardingTemplate) error - DeleteTemplate(ctx context.Context, id uint) error - // Workflows CreateWorkflow(ctx context.Context, w *OnboardingWorkflow) error CreateTasks(ctx context.Context, tasks []OnboardingTask) error @@ -30,6 +23,7 @@ type Repository interface { CountPendingTasks(ctx context.Context, workflowID uint) (int64, error) CountTotalTasks(ctx context.Context, workflowID uint) (int64, error) MarkWorkflowCompleted(ctx context.Context, id uint) error + DeletePendingTasks(ctx context.Context, workflowID uint) error } type repository struct { @@ -44,43 +38,6 @@ func (r *repository) getDB(ctx context.Context) *gorm.DB { return utils.TenantScope(ctx, utils.GetDBFromContext(ctx, r.db)) } -// ── Templates ───────────────────────────────────────────────────────────────── - -func (r *repository) CreateTemplate(ctx context.Context, t *OnboardingTemplate) error { - return r.getDB(ctx).Create(t).Error -} - -func (r *repository) FindAllTemplates(ctx context.Context) ([]OnboardingTemplate, error) { - var templates []OnboardingTemplate - err := r.getDB(ctx).Preload("Items", func(db *gorm.DB) *gorm.DB { - return db.Order("sort_order ASC") - }).Order("department ASC, name ASC").Find(&templates).Error - return templates, err -} - -func (r *repository) FindTemplateByID(ctx context.Context, id uint) (*OnboardingTemplate, error) { - var t OnboardingTemplate - err := r.getDB(ctx).Preload("Items", func(db *gorm.DB) *gorm.DB { - return db.Order("sort_order ASC") - }).First(&t, id).Error - if err != nil { - return nil, err - } - return &t, nil -} - -func (r *repository) UpdateTemplate(ctx context.Context, t *OnboardingTemplate) error { - db := r.getDB(ctx) - // Replace items: delete old, create new - if err := db.Where("template_id = ?", t.ID).Delete(&OnboardingTemplateItem{}).Error; err != nil { - return err - } - return db.Session(&gorm.Session{FullSaveAssociations: true}).Save(t).Error -} - -func (r *repository) DeleteTemplate(ctx context.Context, id uint) error { - return r.getDB(ctx).Delete(&OnboardingTemplate{}, id).Error -} // ── Workflows ───────────────────────────────────────────────────────────────── @@ -120,7 +77,7 @@ func (r *repository) FindAllWorkflows(ctx context.Context, filter *WorkflowFilte func (r *repository) FindWorkflowByID(ctx context.Context, id uint) (*OnboardingWorkflow, error) { var w OnboardingWorkflow err := r.getDB(ctx).Preload("Tasks", func(db *gorm.DB) *gorm.DB { - return db.Order("department ASC, sort_order ASC") + return db.Order("sort_order ASC") }).Preload("Tasks.CompletedByUser.Employee").First(&w, id).Error if err != nil { return nil, err @@ -174,3 +131,8 @@ func (r *repository) MarkWorkflowCompleted(ctx context.Context, id uint) error { return r.getDB(ctx).Model(&OnboardingWorkflow{}).Where("id = ?", id). Update("status", WorkflowStatusCompleted).Error } + +func (r *repository) DeletePendingTasks(ctx context.Context, workflowID uint) error { + return r.getDB(ctx).Where("onboarding_workflow_id = ? AND is_completed = ?", workflowID, false). + Delete(&OnboardingTask{}).Error +} diff --git a/backend/internal/modules/onboarding/repository_test.go b/backend/internal/modules/onboarding/repository_test.go index 935e56d..691f86f 100644 --- a/backend/internal/modules/onboarding/repository_test.go +++ b/backend/internal/modules/onboarding/repository_test.go @@ -14,8 +14,6 @@ import ( func setupOnboardingTestDB(t *testing.T) *testutil.TestDB { t.Helper() tdb := testutil.NewTestDB( - &OnboardingTemplate{}, - &OnboardingTemplateItem{}, &OnboardingWorkflow{}, &OnboardingTask{}, &user.User{}, @@ -25,165 +23,6 @@ func setupOnboardingTestDB(t *testing.T) *testutil.TestDB { return tdb } -func seedOnboardingData(t *testing.T, db *testutil.TestDB) { - t.Helper() - - tmpl := &OnboardingTemplate{ - CompanyID: 1, - Name: "IT Setup", - Department: "IT", - Items: []OnboardingTemplateItem{ - {CompanyID: 1, TaskName: "Create Email", Description: "Setup email", SortOrder: 1}, - {CompanyID: 1, TaskName: "Setup Laptop", Description: "Provision laptop", SortOrder: 2}, - }, - } - require.NoError(t, db.DB.Create(tmpl).Error) - - usr := &user.User{ID: 1, Username: "admin", PasswordHash: "hash", RoleID: 1, CompanyID: 1, IsActive: true} - require.NoError(t, db.DB.Create(usr).Error) - - emp := &user.Employee{ - ID: 1, UserID: 1, CompanyID: 1, DepartmentID: 1, ShiftID: 1, - NIK: "EMP001", FullName: "Admin User", Email: "admin@test.com", Position: "IT Admin", - } - require.NoError(t, db.DB.Create(emp).Error) -} - -func TestRepo_CreateTemplate(t *testing.T) { - tdb := setupOnboardingTestDB(t) - repo := NewRepository(tdb.DB) - ctx := testutil.CtxWithTenant(1, 1, false) - - tests := []struct { - name string - tmpl *OnboardingTemplate - wantErr bool - }{ - { - name: "success", - tmpl: &OnboardingTemplate{ - CompanyID: 1, - Name: "HR Onboarding", - Department: "HR", - Items: []OnboardingTemplateItem{ - {CompanyID: 1, TaskName: "Sign NDA", SortOrder: 1}, - }, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := repo.CreateTemplate(ctx, tt.tmpl) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.NotZero(t, tt.tmpl.ID) - } - }) - } -} - -func TestRepo_FindAllTemplates(t *testing.T) { - tdb := setupOnboardingTestDB(t) - repo := NewRepository(tdb.DB) - ctx := testutil.CtxWithTenant(1, 1, false) - - seedOnboardingData(t, tdb) - - templates, err := repo.FindAllTemplates(ctx) - require.NoError(t, err) - assert.Len(t, templates, 1) - assert.Equal(t, "IT Setup", templates[0].Name) - assert.Len(t, templates[0].Items, 2) -} - -func TestRepo_FindTemplateByID(t *testing.T) { - tdb := setupOnboardingTestDB(t) - repo := NewRepository(tdb.DB) - ctx := testutil.CtxWithTenant(1, 1, false) - - seedOnboardingData(t, tdb) - - tests := []struct { - name string - id uint - wantErr bool - }{ - {name: "success", id: 1, wantErr: false}, - {name: "not found", id: 999, wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpl, err := repo.FindTemplateByID(ctx, tt.id) - if tt.wantErr { - require.Error(t, err) - assert.Nil(t, tmpl) - } else { - require.NoError(t, err) - assert.Equal(t, tt.id, tmpl.ID) - assert.Len(t, tmpl.Items, 2) - } - }) - } -} - -func TestRepo_UpdateTemplate(t *testing.T) { - tdb := setupOnboardingTestDB(t) - repo := NewRepository(tdb.DB) - ctx := testutil.CtxWithTenant(1, 1, true) - - seedOnboardingData(t, tdb) - - tmpl, err := repo.FindTemplateByID(ctx, 1) - require.NoError(t, err) - - tmpl.Name = "IT Setup Updated" - tmpl.Items = []OnboardingTemplateItem{ - {CompanyID: 1, TemplateID: 1, TaskName: "Create Email Account", SortOrder: 1}, - } - - err = repo.UpdateTemplate(ctx, tmpl) - require.NoError(t, err) - - updated, err := repo.FindTemplateByID(ctx, 1) - require.NoError(t, err) - assert.Equal(t, "IT Setup Updated", updated.Name) - assert.Len(t, updated.Items, 1) - assert.Equal(t, "Create Email Account", updated.Items[0].TaskName) -} - -func TestRepo_DeleteTemplate(t *testing.T) { - tdb := setupOnboardingTestDB(t) - repo := NewRepository(tdb.DB) - ctx := testutil.CtxWithTenant(1, 1, false) - - seedOnboardingData(t, tdb) - - tests := []struct { - name string - id uint - wantErr bool - }{ - {name: "success", id: 1, wantErr: false}, - {name: "not found", id: 999, wantErr: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := repo.DeleteTemplate(ctx, tt.id) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - func TestRepo_CreateWorkflow(t *testing.T) { tdb := setupOnboardingTestDB(t) repo := NewRepository(tdb.DB) @@ -228,14 +67,12 @@ func TestRepo_CreateTasks(t *testing.T) { CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Create Email", - Department: "IT", SortOrder: 1, }, { CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Sign NDA", - Department: "HR", SortOrder: 2, }, } @@ -276,7 +113,6 @@ func TestRepo_FindAllWorkflows(t *testing.T) { CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Create Email", - Department: "IT", SortOrder: 1, } require.NoError(t, tdb.DB.Create(task).Error) @@ -337,7 +173,13 @@ func TestRepo_FindWorkflowByID(t *testing.T) { repo := NewRepository(tdb.DB) ctx := testutil.CtxWithTenant(1, 1, false) - seedOnboardingData(t, tdb) + usr := &user.User{ID: 1, Username: "admin", PasswordHash: "hash", RoleID: 1, CompanyID: 1, IsActive: true} + require.NoError(t, tdb.DB.Create(usr).Error) + emp := &user.Employee{ + ID: 1, UserID: 1, CompanyID: 1, DepartmentID: 1, ShiftID: 1, + NIK: "EMP001", FullName: "Admin User", Email: "admin@test.com", Position: "IT Admin", + } + require.NoError(t, tdb.DB.Create(emp).Error) startDate := time.Now() wf := &OnboardingWorkflow{ @@ -355,7 +197,6 @@ func TestRepo_FindWorkflowByID(t *testing.T) { CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Create Email", - Department: "IT", SortOrder: 1, } require.NoError(t, tdb.DB.Create(task).Error) @@ -422,7 +263,6 @@ func TestRepo_FindTaskByID(t *testing.T) { CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Create Email", - Department: "IT", SortOrder: 1, } require.NoError(t, tdb.DB.Create(task).Error) @@ -454,7 +294,13 @@ func TestRepo_CompleteTask(t *testing.T) { repo := NewRepository(tdb.DB) ctx := testutil.CtxWithTenant(1, 1, false) - seedOnboardingData(t, tdb) + usr := &user.User{ID: 1, Username: "admin", PasswordHash: "hash", RoleID: 1, CompanyID: 1, IsActive: true} + require.NoError(t, tdb.DB.Create(usr).Error) + emp := &user.Employee{ + ID: 1, UserID: 1, CompanyID: 1, DepartmentID: 1, ShiftID: 1, + NIK: "EMP001", FullName: "Admin User", Email: "admin@test.com", Position: "IT Admin", + } + require.NoError(t, tdb.DB.Create(emp).Error) wf := &OnboardingWorkflow{ CompanyID: 1, @@ -468,7 +314,6 @@ func TestRepo_CompleteTask(t *testing.T) { CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Create Email", - Department: "IT", SortOrder: 1, } require.NoError(t, tdb.DB.Create(task).Error) @@ -496,8 +341,8 @@ func TestRepo_CountPendingTasks(t *testing.T) { require.NoError(t, repo.CreateWorkflow(ctx, wf)) tasks := []OnboardingTask{ - {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 1", Department: "IT", IsCompleted: false}, - {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 2", Department: "HR", IsCompleted: true}, + {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 1", IsCompleted: false}, + {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 2", IsCompleted: true}, } require.NoError(t, tdb.DB.Create(&tasks).Error) @@ -520,8 +365,8 @@ func TestRepo_CountTotalTasks(t *testing.T) { require.NoError(t, repo.CreateWorkflow(ctx, wf)) tasks := []OnboardingTask{ - {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 1", Department: "IT"}, - {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 2", Department: "HR"}, + {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 1"}, + {CompanyID: 1, OnboardingWorkflowID: wf.ID, TaskName: "Task 2"}, } require.NoError(t, tdb.DB.Create(&tasks).Error) diff --git a/backend/internal/modules/onboarding/service.go b/backend/internal/modules/onboarding/service.go index bfa115b..9ed5ee6 100644 --- a/backend/internal/modules/onboarding/service.go +++ b/backend/internal/modules/onboarding/service.go @@ -17,13 +17,6 @@ import ( ) type Service interface { - // Templates - CreateTemplate(ctx context.Context, req *CreateTemplateRequest) error - GetTemplates(ctx context.Context) ([]TemplateResponse, error) - GetTemplateByID(ctx context.Context, id uint) (*TemplateResponse, error) - UpdateTemplate(ctx context.Context, id uint, req *UpdateTemplateRequest) error - DeleteTemplate(ctx context.Context, id uint) error - // Workflows CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) error GetWorkflows(ctx context.Context, filter *WorkflowFilter) ([]WorkflowListResponse, *response.Meta, error) @@ -31,6 +24,7 @@ type Service interface { // Tasks CompleteTask(ctx context.Context, taskID uint, completedByID uint, req *CompleteTaskRequest) error + UpdateWorkflowTasks(ctx context.Context, workflowID uint, req *UpdateWorkflowTasksRequest) error } type service struct { @@ -59,99 +53,6 @@ func NewService( return &service{repo, notification, user, email, company, role, department, master, transaction} } -// ── Templates ───────────────────────────────────────────────────────────────── - -func (s *service) CreateTemplate(ctx context.Context, req *CreateTemplateRequest) error { - return s.transaction.RunInTransaction(ctx, func(ctx context.Context) error { - t := &OnboardingTemplate{ - CompanyID: utils.GetCompanyIDFromCtx(ctx), - Name: req.Name, - Department: req.Department, - } - - for _, item := range req.Items { - t.Items = append(t.Items, OnboardingTemplateItem{ - CompanyID: utils.GetCompanyIDFromCtx(ctx), - TaskName: item.TaskName, - Description: item.Description, - SortOrder: item.SortOrder, - }) - } - - err := s.repo.CreateTemplate(ctx, t) - if err != nil { - return err - } - - return nil - }) -} - -func (s *service) GetTemplates(ctx context.Context) ([]TemplateResponse, error) { - templates, err := s.repo.FindAllTemplates(ctx) - if err != nil { - return nil, err - } - result := make([]TemplateResponse, 0, len(templates)) - for _, t := range templates { - result = append(result, toTemplateResponse(t)) - } - return result, nil -} - -func (s *service) GetTemplateByID(ctx context.Context, id uint) (*TemplateResponse, error) { - t, err := s.repo.FindTemplateByID(ctx, id) - if err != nil { - return nil, errors.New("template not found") - } - r := toTemplateResponse(*t) - return &r, nil -} - -func (s *service) UpdateTemplate(ctx context.Context, id uint, req *UpdateTemplateRequest) error { - return s.transaction.RunInTransaction(ctx, func(ctx context.Context) error { - existing, err := s.repo.FindTemplateByID(ctx, id) - if err != nil { - return errors.New("template not found") - } - - existing.Name = req.Name - existing.Department = req.Department - existing.Items = nil - for _, item := range req.Items { - existing.Items = append(existing.Items, OnboardingTemplateItem{ - CompanyID: utils.GetCompanyIDFromCtx(ctx), - TemplateID: id, - TaskName: item.TaskName, - Description: item.Description, - SortOrder: item.SortOrder, - }) - } - - err = s.repo.UpdateTemplate(ctx, existing) - if err != nil { - return err - } - - return nil - }) -} - -func (s *service) DeleteTemplate(ctx context.Context, id uint) error { - return s.transaction.RunInTransaction(ctx, func(ctx context.Context) error { - _, err := s.repo.FindTemplateByID(ctx, id) - if err != nil { - return errors.New("template not found") - } - - err = s.repo.DeleteTemplate(ctx, id) - if err != nil { - return err - } - - return nil - }) -} // ── Workflows ───────────────────────────────────────────────────────────────── @@ -179,28 +80,20 @@ func (s *service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest return err } - // Copy all template items into workflow tasks - templates, err := s.repo.FindAllTemplates(ctx) - if err != nil { - logger.Errorf("onboarding: failed to load templates: %v", err) - } else { + // Create tasks from request + if len(req.Tasks) > 0 { var tasks []OnboardingTask - for _, tmpl := range templates { - for _, item := range tmpl.Items { - itemID := item.ID - tasks = append(tasks, OnboardingTask{ - CompanyID: utils.GetCompanyIDFromCtx(ctx), - OnboardingWorkflowID: workflow.ID, - TemplateItemID: &itemID, - TaskName: item.TaskName, - Description: item.Description, - Department: tmpl.Department, - SortOrder: item.SortOrder, - }) - } + for _, t := range req.Tasks { + tasks = append(tasks, OnboardingTask{ + CompanyID: utils.GetCompanyIDFromCtx(ctx), + OnboardingWorkflowID: workflow.ID, + TaskName: t.TaskName, + Description: t.Description, + SortOrder: t.SortOrder, + }) } if err := s.repo.CreateTasks(ctx, tasks); err != nil { - logger.Errorf("onboarding: failed to create tasks: %v", err) + return err } } @@ -305,21 +198,11 @@ func (s *service) GetWorkflowDetail(ctx context.Context, id uint) (*WorkflowDeta Progress: progress, WelcomeEmailSent: w.WelcomeEmailSent, CreatedAt: w.CreatedAt, - ITTasks: []TaskResponse{}, - HRTasks: []TaskResponse{}, - OtherTasks: []TaskResponse{}, + Tasks: []TaskResponse{}, } for _, t := range w.Tasks { - tr := toTaskResponse(t) - switch t.Department { - case "IT": - detail.ITTasks = append(detail.ITTasks, tr) - case "HR": - detail.HRTasks = append(detail.HRTasks, tr) - default: - detail.OtherTasks = append(detail.OtherTasks, tr) - } + detail.Tasks = append(detail.Tasks, toTaskResponse(t)) } return detail, nil @@ -389,6 +272,34 @@ func (s *service) CompleteTask(ctx context.Context, taskID uint, completedByID u }) } +// ── Update Tasks ──────────────────────────────────────────────────────────── + +func (s *service) UpdateWorkflowTasks(ctx context.Context, workflowID uint, req *UpdateWorkflowTasksRequest) error { + return s.transaction.RunInTransaction(ctx, func(ctx context.Context) error { + _, err := s.repo.FindWorkflowByID(ctx, workflowID) + if err != nil { + return errors.New("workflow not found") + } + + if err := s.repo.DeletePendingTasks(ctx, workflowID); err != nil { + return err + } + + var tasks []OnboardingTask + for _, t := range req.Tasks { + tasks = append(tasks, OnboardingTask{ + CompanyID: utils.GetCompanyIDFromCtx(ctx), + OnboardingWorkflowID: workflowID, + TaskName: t.TaskName, + Description: t.Description, + SortOrder: t.SortOrder, + }) + } + + return s.repo.CreateTasks(ctx, tasks) + }) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── func (s *service) sendWelcomeEmail(w *OnboardingWorkflow) error { @@ -429,31 +340,11 @@ func (s *service) sendWelcomeEmail(w *OnboardingWorkflow) error { return s.email.Send(w.NewHireEmail, "Welcome to "+company.Name+"!", buf.String()) } -func toTemplateResponse(t OnboardingTemplate) TemplateResponse { - r := TemplateResponse{ - ID: t.ID, - Name: t.Name, - Department: t.Department, - CreatedAt: t.CreatedAt, - Items: []TemplateItemResponse{}, - } - for _, item := range t.Items { - r.Items = append(r.Items, TemplateItemResponse{ - ID: item.ID, - TaskName: item.TaskName, - Description: item.Description, - SortOrder: item.SortOrder, - }) - } - return r -} - func toTaskResponse(t OnboardingTask) TaskResponse { tr := TaskResponse{ ID: t.ID, TaskName: t.TaskName, Description: t.Description, - Department: t.Department, IsCompleted: t.IsCompleted, CompletedAt: t.CompletedAt, Notes: t.Notes, diff --git a/backend/internal/modules/onboarding/service_test.go b/backend/internal/modules/onboarding/service_test.go index 6e20eed..f21e747 100644 --- a/backend/internal/modules/onboarding/service_test.go +++ b/backend/internal/modules/onboarding/service_test.go @@ -16,290 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestService_CreateTemplate(t *testing.T) { - ctx := testutil.CtxWithTenant(1, 1, false) - - tests := []struct { - name string - req *CreateTemplateRequest - setupMocks func(*mockRepo) - wantErr bool - errMsg string - }{ - { - name: "success", - req: &CreateTemplateRequest{ - Name: "IT Setup", - Department: "IT", - Items: []TemplateItemRequest{ - {TaskName: "Create Email", Description: "Setup email", SortOrder: 1}, - }, - }, - setupMocks: func(repo *mockRepo) { - repo.On("CreateTemplate", mock.Anything, mock.AnythingOfType("*onboarding.OnboardingTemplate")).Return(nil) - }, - wantErr: false, - }, - { - name: "repo error", - req: &CreateTemplateRequest{ - Name: "IT Setup", - Department: "IT", - }, - setupMocks: func(repo *mockRepo) { - repo.On("CreateTemplate", mock.Anything, mock.AnythingOfType("*onboarding.OnboardingTemplate")).Return(errors.New("db error")) - }, - wantErr: true, - errMsg: "db error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _, _, _, _, _, _ := newTestOnboardingService() - tt.setupMocks(repo) - - err := svc.CreateTemplate(ctx, tt.req) - - if tt.wantErr { - require.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestService_GetTemplates(t *testing.T) { - tests := []struct { - name string - setupMocks func(*mockRepo) - wantLen int - wantErr bool - }{ - { - name: "success with data", - setupMocks: func(repo *mockRepo) { - repo.On("FindAllTemplates", mock.Anything).Return([]OnboardingTemplate{ - {ID: 1, Name: "IT Setup", Department: "IT", Items: []OnboardingTemplateItem{}}, - }, nil) - }, - wantLen: 1, - wantErr: false, - }, - { - name: "success empty", - setupMocks: func(repo *mockRepo) { - repo.On("FindAllTemplates", mock.Anything).Return([]OnboardingTemplate{}, nil) - }, - wantLen: 0, - wantErr: false, - }, - { - name: "repo error", - setupMocks: func(repo *mockRepo) { - repo.On("FindAllTemplates", mock.Anything).Return(nil, errors.New("db error")) - }, - wantLen: 0, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _, _, _, _, _, _ := newTestOnboardingService() - tt.setupMocks(repo) - - result, err := svc.GetTemplates(testutil.CtxWithTenant(1, 1, false)) - - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.Len(t, result, tt.wantLen) - } - }) - } -} - -func TestService_GetTemplateByID(t *testing.T) { - tests := []struct { - name string - id uint - setupMocks func(*mockRepo) - wantErr bool - errMsg string - }{ - { - name: "success", - id: 1, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(1)).Return(&OnboardingTemplate{ - ID: 1, Name: "IT Setup", Department: "IT", Items: []OnboardingTemplateItem{ - {ID: 1, TaskName: "Create Email", SortOrder: 1}, - }, - }, nil) - }, - wantErr: false, - }, - { - name: "not found", - id: 999, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(999)).Return(nil, errors.New("not found")) - }, - wantErr: true, - errMsg: "template not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _, _, _, _, _, _ := newTestOnboardingService() - tt.setupMocks(repo) - - result, err := svc.GetTemplateByID(testutil.CtxWithTenant(1, 1, false), tt.id) - - if tt.wantErr { - require.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - } else { - require.NoError(t, err) - assert.Equal(t, tt.id, result.ID) - } - }) - } -} - -func TestService_UpdateTemplate(t *testing.T) { - ctx := testutil.CtxWithTenant(1, 1, false) - - tests := []struct { - name string - id uint - req *UpdateTemplateRequest - setupMocks func(*mockRepo) - wantErr bool - errMsg string - }{ - { - name: "success", - id: 1, - req: &UpdateTemplateRequest{ - Name: "IT Setup Updated", - Department: "IT", - Items: []TemplateItemRequest{ - {TaskName: "Create Email", SortOrder: 1}, - }, - }, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(1)).Return(&OnboardingTemplate{ - ID: 1, Name: "IT Setup", Department: "IT", Items: []OnboardingTemplateItem{}, - }, nil) - repo.On("UpdateTemplate", mock.Anything, mock.AnythingOfType("*onboarding.OnboardingTemplate")).Return(nil) - }, - wantErr: false, - }, - { - name: "template not found", - id: 999, - req: &UpdateTemplateRequest{Name: "Test", Department: "IT"}, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(999)).Return(nil, errors.New("not found")) - }, - wantErr: true, - errMsg: "template not found", - }, - { - name: "update error", - id: 1, - req: &UpdateTemplateRequest{Name: "Updated", Department: "IT"}, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(1)).Return(&OnboardingTemplate{ - ID: 1, Name: "IT Setup", Department: "IT", Items: []OnboardingTemplateItem{}, - }, nil) - repo.On("UpdateTemplate", mock.Anything, mock.AnythingOfType("*onboarding.OnboardingTemplate")).Return(errors.New("db error")) - }, - wantErr: true, - errMsg: "db error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _, _, _, _, _, _ := newTestOnboardingService() - tt.setupMocks(repo) - - err := svc.UpdateTemplate(ctx, tt.id, tt.req) - - if tt.wantErr { - require.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestService_DeleteTemplate(t *testing.T) { - ctx := testutil.CtxWithTenant(1, 1, false) - - tests := []struct { - name string - id uint - setupMocks func(*mockRepo) - wantErr bool - errMsg string - }{ - { - name: "success", - id: 1, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(1)).Return(&OnboardingTemplate{ID: 1}, nil) - repo.On("DeleteTemplate", mock.Anything, uint(1)).Return(nil) - }, - wantErr: false, - }, - { - name: "not found", - id: 999, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(999)).Return(nil, errors.New("not found")) - }, - wantErr: true, - errMsg: "template not found", - }, - { - name: "delete error", - id: 1, - setupMocks: func(repo *mockRepo) { - repo.On("FindTemplateByID", mock.Anything, uint(1)).Return(&OnboardingTemplate{ID: 1}, nil) - repo.On("DeleteTemplate", mock.Anything, uint(1)).Return(errors.New("db error")) - }, - wantErr: true, - errMsg: "db error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _, _, _, _, _, _ := newTestOnboardingService() - tt.setupMocks(repo) - - err := svc.DeleteTemplate(ctx, tt.id) - - if tt.wantErr { - require.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - } else { - require.NoError(t, err) - } - }) - } -} - func TestService_CreateWorkflow(t *testing.T) { ctx := testutil.CtxWithTenant(1, 1, false) @@ -318,14 +34,12 @@ func TestService_CreateWorkflow(t *testing.T) { Position: "Developer", Department: "Engineering", StartDate: "2025-01-15", + Tasks: []WorkflowTaskRequest{ + {TaskName: "Create Email", Description: "Setup email", SortOrder: 1}, + }, }, setupMocks: func(repo *mockRepo, userProv *mockUserProvider, emailProv *mockEmailProvider, companyProv *mockCompanyProvider) { repo.On("CreateWorkflow", mock.Anything, mock.AnythingOfType("*onboarding.OnboardingWorkflow")).Return(nil) - repo.On("FindAllTemplates", mock.Anything).Return([]OnboardingTemplate{ - {ID: 1, CompanyID: 1, Name: "IT Setup", Department: "IT", Items: []OnboardingTemplateItem{ - {ID: 1, CompanyID: 1, TaskName: "Create Email", Description: "Setup email", SortOrder: 1}, - }}, - }, nil) repo.On("CreateTasks", mock.Anything, mock.Anything).Return(nil) repo.On("MarkWorkflowEmailSent", mock.Anything, mock.Anything).Return(nil) companyProv.On("FindByID", mock.Anything, uint(1)).Return(&company.Company{ID: 1, Name: "TestCo"}, nil) @@ -450,9 +164,9 @@ func TestService_GetWorkflowDetail(t *testing.T) { ID: 1, NewHireName: "Jane", NewHireEmail: "jane@example.com", Status: WorkflowStatusInProgress, CreatedAt: now, Tasks: []OnboardingTask{ - {ID: 1, TaskName: "Create Email", Department: "IT", IsCompleted: true, CompletedBy: uintPtr(1), CompletedAt: &now, CompletedByUser: &user.User{Employee: &user.Employee{FullName: "Admin"}}}, - {ID: 2, TaskName: "Sign NDA", Department: "HR", IsCompleted: false}, - {ID: 3, TaskName: "Other Task", Department: "Finance", IsCompleted: false}, + {ID: 1, TaskName: "Create Email", IsCompleted: true, CompletedBy: uintPtr(1), CompletedAt: &now, CompletedByUser: &user.User{Employee: &user.Employee{FullName: "Admin"}}}, + {ID: 2, TaskName: "Sign NDA", IsCompleted: false}, + {ID: 3, TaskName: "Other Task", IsCompleted: false}, }, }, nil) }, @@ -482,9 +196,7 @@ func TestService_GetWorkflowDetail(t *testing.T) { } else { require.NoError(t, err) assert.NotNil(t, result) - assert.Len(t, result.ITTasks, 1) - assert.Len(t, result.HRTasks, 1) - assert.Len(t, result.OtherTasks, 1) + assert.Len(t, result.Tasks, 3) assert.Equal(t, 33, result.Progress) } }) @@ -675,9 +387,7 @@ func TestService_GetWorkflowDetail_ProgressZeroTasks(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, result.Progress) - assert.Empty(t, result.ITTasks) - assert.Empty(t, result.HRTasks) - assert.Empty(t, result.OtherTasks) + assert.Empty(t, result.Tasks) } func uintPtr(v uint) *uint { @@ -715,3 +425,77 @@ func TestService_GetWorkflows_MetaResponse(t *testing.T) { assert.Equal(t, int64(15), meta.TotalData) assert.Equal(t, int64(2), meta.TotalPage) } + +func TestService_UpdateWorkflowTasks(t *testing.T) { + ctx := testutil.CtxWithTenant(1, 1, false) + + tests := []struct { + name string + workflowID uint + req *UpdateWorkflowTasksRequest + setupMocks func(*mockRepo) + wantErr bool + errMsg string + }{ + { + name: "success", + workflowID: 1, + req: &UpdateWorkflowTasksRequest{ + Tasks: []WorkflowTaskRequest{ + {TaskName: "Setup Email", Description: "Create email", SortOrder: 1}, + }, + }, + setupMocks: func(repo *mockRepo) { + repo.On("FindWorkflowByID", mock.Anything, uint(1)).Return(&OnboardingWorkflow{ID: 1}, nil) + repo.On("DeletePendingTasks", mock.Anything, uint(1)).Return(nil) + repo.On("CreateTasks", mock.Anything, mock.Anything).Return(nil) + }, + wantErr: false, + }, + { + name: "workflow not found", + workflowID: 999, + req: &UpdateWorkflowTasksRequest{ + Tasks: []WorkflowTaskRequest{ + {TaskName: "Task", SortOrder: 1}, + }, + }, + setupMocks: func(repo *mockRepo) { + repo.On("FindWorkflowByID", mock.Anything, uint(999)).Return(nil, errors.New("not found")) + }, + wantErr: true, + errMsg: "workflow not found", + }, + { + name: "delete pending tasks error", + workflowID: 1, + req: &UpdateWorkflowTasksRequest{ + Tasks: []WorkflowTaskRequest{ + {TaskName: "Task", SortOrder: 1}, + }, + }, + setupMocks: func(repo *mockRepo) { + repo.On("FindWorkflowByID", mock.Anything, uint(1)).Return(&OnboardingWorkflow{ID: 1}, nil) + repo.On("DeletePendingTasks", mock.Anything, uint(1)).Return(errors.New("db error")) + }, + wantErr: true, + errMsg: "db error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, repo, _, _, _, _, _, _, _, _ := newTestOnboardingService() + tt.setupMocks(repo) + + err := svc.UpdateWorkflowTasks(ctx, tt.workflowID, tt.req) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/backend/internal/modules/subscription/contract.go b/backend/internal/modules/subscription/contract.go index 11c881c..5c1af6d 100644 --- a/backend/internal/modules/subscription/contract.go +++ b/backend/internal/modules/subscription/contract.go @@ -13,7 +13,7 @@ type RoleProvider interface { } type CacheProvider interface { - FlushDB(ctx context.Context) error + Del(ctx context.Context, key string) error } type UserProvider interface { diff --git a/backend/internal/modules/subscription/handler.go b/backend/internal/modules/subscription/handler.go index efceb6d..675250c 100644 --- a/backend/internal/modules/subscription/handler.go +++ b/backend/internal/modules/subscription/handler.go @@ -143,3 +143,15 @@ func (h *Handler) GetDashboardStats(ctx echo.Context) error { } return response.NewResponses[any](ctx, http.StatusOK, "Success", stats, nil, nil) } + +func (h *Handler) RefreshCompanyCache(ctx echo.Context) error { + id := ctx.Param("id") + var companyID uint + if _, err := fmt.Sscanf(id, "%d", &companyID); err != nil { + return response.NewResponses[any](ctx, http.StatusBadRequest, "Invalid company ID", nil, err, nil) + } + + _ = h.service.RefreshCompanyCache(ctx.Request().Context(), companyID) + + return response.NewResponses[any](ctx, http.StatusOK, "Cache refreshed for company", nil, nil, nil) +} diff --git a/backend/internal/modules/subscription/mocks_test.go b/backend/internal/modules/subscription/mocks_test.go index cd3223b..96b4818 100644 --- a/backend/internal/modules/subscription/mocks_test.go +++ b/backend/internal/modules/subscription/mocks_test.go @@ -102,6 +102,14 @@ func (m *mockRepo) GetDashboardStats(ctx context.Context) (*DashboardStatsRespon return args.Get(0).(*DashboardStatsResponse), args.Error(1) } +func (m *mockRepo) FindExpiredCompanies(ctx context.Context) ([]uint, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]uint), args.Error(1) +} + type mockCompanyRepo struct{ mock.Mock } func (m *mockCompanyRepo) FindByID(ctx context.Context, id uint) (*company.Company, error) { @@ -168,8 +176,8 @@ func (m *mockUser) ForceResetPasswordByCompanyID(ctx context.Context, companyID type mockCache struct{ mock.Mock } -func (m *mockCache) FlushDB(ctx context.Context) error { - return m.Called(ctx).Error(0) +func (m *mockCache) Del(ctx context.Context, key string) error { + return m.Called(ctx, key).Error(0) } type mockService struct{ mock.Mock } @@ -238,6 +246,10 @@ func (m *mockService) GetDashboardStats(ctx context.Context) (*DashboardStatsRes return args.Get(0).(*DashboardStatsResponse), args.Error(1) } +func (m *mockService) RefreshCompanyCache(ctx context.Context, companyID uint) error { + return m.Called(ctx, companyID).Error(0) +} + func newTestSubscriptionService() (Service, *mockRepo, *mockCompanyRepo, *mockRole, *mockUser, *mockCache) { repo := new(mockRepo) companyRepo := new(mockCompanyRepo) diff --git a/backend/internal/modules/subscription/plancache.go b/backend/internal/modules/subscription/plancache.go new file mode 100644 index 0000000..4bacce6 --- /dev/null +++ b/backend/internal/modules/subscription/plancache.go @@ -0,0 +1,119 @@ +package subscription + +import ( + "basekarya-backend/internal/infrastructure" + "basekarya-backend/pkg/constants" + "basekarya-backend/pkg/logger" + "basekarya-backend/pkg/utils" + "context" + "encoding/json" + "fmt" + "time" + + "gorm.io/gorm" +) + +type PlanCacheService struct { + db *gorm.DB + redis *infrastructure.RedisClientProvider +} + +type planFeatures struct { + Modules []string `json:"modules"` +} + +func NewPlanCacheService(db *gorm.DB, redis *infrastructure.RedisClientProvider) *PlanCacheService { + return &PlanCacheService{db: db, redis: redis} +} + +func (s *PlanCacheService) HasAccess(ctx context.Context, companyID uint, module string) (bool, error) { + key := fmt.Sprintf(constants.SUBSCRIPTION_FEATURES_CACHE_KEY, companyID) + + featuresJSON, err := s.redis.Get(ctx, key) + if err == nil && featuresJSON != "" { + var features planFeatures + if err := json.Unmarshal([]byte(featuresJSON), &features); err != nil { + return false, fmt.Errorf("failed to parse cached features: %w", err) + } + return moduleInFeatures(features.Modules, module), nil + } + + var dbJSON string + err = s.db.Table("subscription_plans"). + Select("subscription_plans.features"). + Joins("JOIN companies ON companies.subscription_plan_id = subscription_plans.id"). + Where("companies.id = ?", companyID). + Scan(&dbJSON).Error + if err != nil { + return false, fmt.Errorf("failed to query subscription features: %w", err) + } + if dbJSON == "" { + return false, fmt.Errorf("subscription plan not found") + } + + if setErr := s.redis.Set(ctx, key, dbJSON, 24*time.Hour); setErr != nil { + logger.Errorw("PlanCacheService: failed to cache features", "key", key, "err", setErr) + } + + var features planFeatures + if err := json.Unmarshal([]byte(dbJSON), &features); err != nil { + return false, fmt.Errorf("failed to parse features: %w", err) + } + return moduleInFeatures(features.Modules, module), nil +} + +func (s *PlanCacheService) CheckEmployeeLimit(ctx context.Context) (bool, error) { + companyID := utils.GetCompanyIDFromCtx(ctx) + if companyID == 0 { + return true, nil + } + + var maxEmployees int + err := s.db.Table("subscription_plans"). + Select("subscription_plans.max_employees"). + Joins("JOIN companies ON companies.subscription_plan_id = subscription_plans.id"). + Where("companies.id = ?", companyID). + Scan(&maxEmployees).Error + if err != nil { + return false, err + } + + if maxEmployees == 0 { + return true, nil + } + + var count int64 + err = s.db.Table("users"). + Joins("JOIN roles ON roles.id = users.role_id"). + Where("users.company_id = ? AND roles.name = ? AND users.is_active = ?", companyID, "EMPLOYEE", true). + Count(&count).Error + if err != nil { + return false, err + } + + return count < int64(maxEmployees), nil +} + +func (s *PlanCacheService) Del(ctx context.Context, key string) error { + return s.redis.Del(ctx, key) +} + +func (s *PlanCacheService) Invalidate(ctx context.Context, companyID uint) { + key := fmt.Sprintf(constants.SUBSCRIPTION_FEATURES_CACHE_KEY, companyID) + if err := s.redis.Del(ctx, key); err != nil { + logger.Errorw("PlanCacheService: failed to delete features cache", "key", key, "err", err) + } + profileKey := fmt.Sprintf(constants.COMPANY_PROFILE_CACHE_KEY, companyID) + if err := s.redis.Del(ctx, profileKey); err != nil { + logger.Errorw("PlanCacheService: failed to delete company profile cache", "key", profileKey, "err", err) + } +} + +func moduleInFeatures(modules []string, target string) bool { + for _, m := range modules { + if m == target { + return true + } + } + return false +} diff --git a/backend/internal/modules/subscription/plancache_test.go b/backend/internal/modules/subscription/plancache_test.go new file mode 100644 index 0000000..2d15acb --- /dev/null +++ b/backend/internal/modules/subscription/plancache_test.go @@ -0,0 +1,145 @@ +package subscription + +import ( + "basekarya-backend/internal/infrastructure" + "basekarya-backend/pkg/constants" + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupPlanCacheTest(t *testing.T) (*PlanCacheService, *miniredis.Miniredis, *gorm.DB) { + t.Helper() + + mr, err := miniredis.Run() + require.NoError(t, err) + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + redisProvider := &infrastructure.RedisClientProvider{Client: rdb} + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + err = db.Exec(`CREATE TABLE subscription_plans (id INTEGER PRIMARY KEY, name TEXT, slug TEXT, max_employees INTEGER, price_monthly REAL, features TEXT, is_active INTEGER, created_at DATETIME, updated_at DATETIME)`).Error + require.NoError(t, err) + + err = db.Exec(`CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT, subscription_plan_id INTEGER, subscription_status TEXT, subscription_expires_at DATETIME, created_at DATETIME, updated_at DATETIME)`).Error + require.NoError(t, err) + + err = db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, company_id INTEGER, role_id INTEGER, is_active INTEGER, created_at DATETIME, updated_at DATETIME)`).Error + require.NoError(t, err) + + err = db.Exec(`CREATE TABLE roles (id INTEGER PRIMARY KEY, name TEXT, company_id INTEGER, created_at DATETIME, updated_at DATETIME)`).Error + require.NoError(t, err) + + svc := &PlanCacheService{db: db, redis: redisProvider} + return svc, mr, db +} + +func TestPlanCacheService_RedisHit(t *testing.T) { + svc, mr, db := setupPlanCacheTest(t) + defer mr.Close() + + db.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active) VALUES (1, 'Pro', 'pro', 10, 99.00, '{"modules":["payroll","attendance"]}', 1)`) + db.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status) VALUES (1, 'TestCo', 1, 'ACTIVE')`) + + mr.Set("subscription:features:1", `{"modules":["payroll","attendance"]}`) + + hasAccess, err := svc.HasAccess(context.Background(), 1, "payroll") + require.NoError(t, err) + assert.True(t, hasAccess) +} + +func TestPlanCacheService_RedisMiss_PopulatesCache(t *testing.T) { + svc, mr, db := setupPlanCacheTest(t) + defer mr.Close() + + db.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active) VALUES (1, 'Basic', 'basic', 5, 29.00, '{"modules":["attendance"]}', 1)`) + db.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status) VALUES (1, 'TestCo', 1, 'ACTIVE')`) + + hasAccess, err := svc.HasAccess(context.Background(), 1, "attendance") + require.NoError(t, err) + assert.True(t, hasAccess) + + cached, err := mr.Get("subscription:features:1") + require.NoError(t, err) + assert.Equal(t, `{"modules":["attendance"]}`, cached) + + ttl := mr.TTL("subscription:features:1") + assert.InDelta(t, 24*time.Hour.Seconds(), ttl.Seconds(), 5) +} + +func TestPlanCacheService_ModuleFound(t *testing.T) { + svc, mr, db := setupPlanCacheTest(t) + defer mr.Close() + + db.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active) VALUES (1, 'Pro', 'pro', 10, 99.00, '{"modules":["payroll","attendance","recruitment"]}', 1)`) + db.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status) VALUES (1, 'TestCo', 1, 'ACTIVE')`) + + hasAccess, err := svc.HasAccess(context.Background(), 1, "recruitment") + require.NoError(t, err) + assert.True(t, hasAccess) +} + +func TestPlanCacheService_ModuleNotFound(t *testing.T) { + svc, mr, db := setupPlanCacheTest(t) + defer mr.Close() + + db.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active) VALUES (1, 'Basic', 'basic', 5, 29.00, '{"modules":["attendance"]}', 1)`) + db.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status) VALUES (1, 'TestCo', 1, 'ACTIVE')`) + + hasAccess, err := svc.HasAccess(context.Background(), 1, "payroll") + require.NoError(t, err) + assert.False(t, hasAccess) +} + +func TestPlanCacheService_NoPlan(t *testing.T) { + svc, mr, db := setupPlanCacheTest(t) + defer mr.Close() + + db.Exec(`INSERT INTO companies (id, name, subscription_status) VALUES (1, 'TestCo', 'ACTIVE')`) + + hasAccess, err := svc.HasAccess(context.Background(), 1, "payroll") + require.Error(t, err) + assert.False(t, hasAccess) + assert.Contains(t, err.Error(), "not found") +} + +func TestPlanCacheService_Invalidate(t *testing.T) { + svc, mr, _ := setupPlanCacheTest(t) + defer mr.Close() + + mr.Set("subscription:features:1", `{"modules":["payroll"]}`) + mr.Set("company:profile:1", `{"name":"TestCo"}`) + + svc.Invalidate(context.Background(), 1) + + _, err := mr.Get("subscription:features:1") + assert.Equal(t, miniredis.ErrKeyNotFound, err) + + _, err = mr.Get("company:profile:1") + assert.Equal(t, miniredis.ErrKeyNotFound, err) +} + +func TestPlanCacheService_CheckEmployeeLimit(t *testing.T) { + svc, mr, db := setupPlanCacheTest(t) + defer mr.Close() + + db.Exec(`INSERT INTO subscription_plans (id, name, slug, max_employees, price_monthly, features, is_active) VALUES (1, 'Basic', 'basic', 3, 29.00, '{"modules":["attendance"]}', 1)`) + db.Exec(`INSERT INTO companies (id, name, subscription_plan_id, subscription_status) VALUES (1, 'TestCo', 1, 'ACTIVE')`) + db.Exec(`INSERT INTO roles (id, name, company_id) VALUES (1, 'EMPLOYEE', 1)`) + db.Exec(`INSERT INTO users (id, company_id, role_id, is_active) VALUES (1, 1, 1, 1), (2, 1, 1, 1), (3, 1, 1, 1)`) + + ctx := context.WithValue(context.Background(), constants.CompanyIDContextKey, uint(1)) + + allowed, err := svc.CheckEmployeeLimit(ctx) + require.NoError(t, err) + assert.False(t, allowed) +} diff --git a/backend/internal/modules/subscription/repository.go b/backend/internal/modules/subscription/repository.go index 166bf03..9618f49 100644 --- a/backend/internal/modules/subscription/repository.go +++ b/backend/internal/modules/subscription/repository.go @@ -21,6 +21,7 @@ type Repository interface { FindCompanyDetailByID(ctx context.Context, id uint) (*CompanyDetail, error) UpdateCompanyStatus(ctx context.Context, companyID uint, status string) error GetDashboardStats(ctx context.Context) (*DashboardStatsResponse, error) + FindExpiredCompanies(ctx context.Context) ([]uint, error) } type repository struct { @@ -206,3 +207,13 @@ func (r *repository) GetDashboardStats(ctx context.Context) (*DashboardStatsResp return &stats, nil } + +func (r *repository) FindExpiredCompanies(ctx context.Context) ([]uint, error) { + var ids []uint + err := r.db.WithContext(ctx).Table("companies"). + Select("id"). + Where("subscription_status != ?", constants.SubStatusExpired). + Where("subscription_expires_at IS NOT NULL AND subscription_expires_at < NOW()"). + Pluck("id", &ids).Error + return ids, err +} diff --git a/backend/internal/modules/subscription/scheduler.go b/backend/internal/modules/subscription/scheduler.go new file mode 100644 index 0000000..722c58f --- /dev/null +++ b/backend/internal/modules/subscription/scheduler.go @@ -0,0 +1,69 @@ +package subscription + +import ( + "basekarya-backend/internal/infrastructure" + "basekarya-backend/pkg/constants" + "basekarya-backend/pkg/logger" + "context" + "fmt" +) + +type Scheduler interface { + Start() + Stop() +} + +type subscriptionScheduler struct { + cronProvider *infrastructure.CronProvider + repo Repository + cache CacheProvider +} + +func NewScheduler(cronProvider *infrastructure.CronProvider, repo Repository, cache CacheProvider) Scheduler { + return &subscriptionScheduler{cronProvider, repo, cache} +} + +func (sch *subscriptionScheduler) Start() { + logger.Info("Subscription Expiry Scheduler Started...") + + _, err := sch.cronProvider.GetCron().AddFunc("0 0 * * *", func() { + logger.Info("[SCHEDULER] Starting subscription expiry check...") + + ctx := context.Background() + + ids, err := sch.repo.FindExpiredCompanies(ctx) + if err != nil { + logger.Errorf("[SCHEDULER] Failed to find expired companies: %v", err) + return + } + + for _, companyID := range ids { + if err := sch.repo.UpdateCompanyStatus(ctx, companyID, constants.SubStatusExpired); err != nil { + logger.Errorf("[SCHEDULER] Failed to update status for company %d: %v", companyID, err) + continue + } + + _ = sch.cache.Del(ctx, fmt.Sprintf(constants.SUBSCRIPTION_FEATURES_CACHE_KEY, companyID)) + _ = sch.cache.Del(ctx, fmt.Sprintf(constants.COMPANY_PROFILE_CACHE_KEY, companyID)) + } + + if len(ids) > 0 { + logger.Infof("[SCHEDULER] Expired %d companies", len(ids)) + } else { + logger.Info("[SCHEDULER] No expired companies found") + } + }) + + if err != nil { + logger.Errorf("Failed to start subscription expiry scheduler: %v", err) + } + + sch.cronProvider.GetCron().Start() +} + +func (sch *subscriptionScheduler) Stop() { + if sch.cronProvider != nil && sch.cronProvider.GetCron() != nil { + sch.cronProvider.GetCron().Stop() + logger.Info("Subscription Expiry Scheduler Stopped.") + } +} diff --git a/backend/internal/modules/subscription/service.go b/backend/internal/modules/subscription/service.go index bb3f3f5..e545703 100644 --- a/backend/internal/modules/subscription/service.go +++ b/backend/internal/modules/subscription/service.go @@ -19,6 +19,7 @@ type Service interface { ListCompanies(ctx context.Context, search string) ([]CompanyListItem, error) GetCompanyDetail(ctx context.Context, id uint) (*CompanyDetail, error) UpdateCompanyStatus(ctx context.Context, companyID uint, req *UpdateCompanyStatusRequest) error + RefreshCompanyCache(ctx context.Context, companyID uint) error GetDashboardStats(ctx context.Context) (*DashboardStatsResponse, error) } @@ -164,7 +165,8 @@ func (s *service) ReviewRequest(ctx context.Context, requestID uint, req *Review } } - _ = s.cache.FlushDB(context.Background()) + _ = s.cache.Del(ctx, fmt.Sprintf(constants.SUBSCRIPTION_FEATURES_CACHE_KEY, subReq.CompanyID)) + _ = s.cache.Del(ctx, fmt.Sprintf(constants.COMPANY_PROFILE_CACHE_KEY, subReq.CompanyID)) _ = s.user.ForceResetPasswordByCompanyID(ctx, subReq.CompanyID) } @@ -185,13 +187,26 @@ func (s *service) GetCompanyDetail(ctx context.Context, id uint) (*CompanyDetail } func (s *service) UpdateCompanyStatus(ctx context.Context, companyID uint, req *UpdateCompanyStatusRequest) error { - return s.repo.UpdateCompanyStatus(ctx, companyID, req.SubscriptionStatus) + if err := s.repo.UpdateCompanyStatus(ctx, companyID, req.SubscriptionStatus); err != nil { + return err + } + + _ = s.cache.Del(ctx, fmt.Sprintf(constants.SUBSCRIPTION_FEATURES_CACHE_KEY, companyID)) + _ = s.cache.Del(ctx, fmt.Sprintf(constants.COMPANY_PROFILE_CACHE_KEY, companyID)) + + return nil } func (s *service) GetDashboardStats(ctx context.Context) (*DashboardStatsResponse, error) { return s.repo.GetDashboardStats(ctx) } +func (s *service) RefreshCompanyCache(ctx context.Context, companyID uint) error { + s.cache.Del(ctx, fmt.Sprintf(constants.SUBSCRIPTION_FEATURES_CACHE_KEY, companyID)) + s.cache.Del(ctx, fmt.Sprintf(constants.COMPANY_PROFILE_CACHE_KEY, companyID)) + return nil +} + func buildAllowedGroups(planSlug string) []string { groups := make([]string, len(constants.AlwaysAvailableGroups)) copy(groups, constants.AlwaysAvailableGroups) diff --git a/backend/internal/modules/subscription/service_test.go b/backend/internal/modules/subscription/service_test.go index d1e9350..4c4a589 100644 --- a/backend/internal/modules/subscription/service_test.go +++ b/backend/internal/modules/subscription/service_test.go @@ -297,7 +297,8 @@ func TestService_ReviewRequest(t *testing.T) { role.On("FindPermissionIDsByGroupNames", mock.Anything, mock.Anything).Return([]uint{1, 2}, nil) role.On("FindRoleIDsByCompanyID", mock.Anything, uint(1)).Return([]uint{1}, nil) role.On("AssignPermissions", mock.Anything, uint(1), []uint{1, 2}, uint(1)).Return(nil) - cache.On("FlushDB", mock.Anything).Return(nil) + cache.On("Del", mock.Anything, "subscription:features:1").Return(nil) + cache.On("Del", mock.Anything, "company:profile:1").Return(nil) user.On("ForceResetPasswordByCompanyID", mock.Anything, uint(1)).Return(nil) }, wantErr: false, @@ -523,6 +524,7 @@ func TestService_UpdateCompanyStatus(t *testing.T) { companyID uint req *UpdateCompanyStatusRequest setupMocks func(*mockRepo) + setupCache func(*mockCache) wantErr bool }{ { @@ -532,6 +534,10 @@ func TestService_UpdateCompanyStatus(t *testing.T) { setupMocks: func(repo *mockRepo) { repo.On("UpdateCompanyStatus", mock.Anything, uint(1), constants.SubStatusActive).Return(nil) }, + setupCache: func(cache *mockCache) { + cache.On("Del", mock.Anything, "subscription:features:1").Return(nil) + cache.On("Del", mock.Anything, "company:profile:1").Return(nil) + }, wantErr: false, }, { @@ -547,8 +553,11 @@ func TestService_UpdateCompanyStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _, _ := newTestSubscriptionService() + svc, repo, _, _, _, cache := newTestSubscriptionService() tt.setupMocks(repo) + if tt.setupCache != nil { + tt.setupCache(cache) + } err := svc.UpdateCompanyStatus(testutil.CtxWithTenant(1, 1, true), tt.companyID, tt.req) diff --git a/backend/internal/routes/onboarding.go b/backend/internal/routes/onboarding.go index e8e920f..7991051 100644 --- a/backend/internal/routes/onboarding.go +++ b/backend/internal/routes/onboarding.go @@ -9,17 +9,13 @@ import ( func (r *Router) SetupOnboardingRoutes(e *echo.Group, sub *middleware.SubscriptionMiddleware) { g := e.Group("", sub.RequireModule("onboarding")) - // Templates (require MANAGE_ONBOARDING_TEMPLATE) - g.POST("/templates", r.container.OnboardingHandler.CreateTemplate, r.container.AuthMiddleware.GrantPermission(constants.MANAGE_ONBOARDING_TEMPLATE)) - g.GET("/templates", r.container.OnboardingHandler.GetTemplates, r.container.AuthMiddleware.GrantPermission(constants.MANAGE_ONBOARDING_TEMPLATE)) - g.PUT("/templates/:id", r.container.OnboardingHandler.UpdateTemplate, r.container.AuthMiddleware.GrantPermission(constants.MANAGE_ONBOARDING_TEMPLATE)) - g.DELETE("/templates/:id", r.container.OnboardingHandler.DeleteTemplate, r.container.AuthMiddleware.GrantPermission(constants.MANAGE_ONBOARDING_TEMPLATE)) - // Workflows (require VIEW_ONBOARDING / MANAGE_ONBOARDING_TEMPLATE for creation) - g.POST("/workflows", r.container.OnboardingHandler.CreateWorkflow, r.container.AuthMiddleware.GrantPermission(constants.MANAGE_ONBOARDING_TEMPLATE)) + // Workflows + g.POST("/workflows", r.container.OnboardingHandler.CreateWorkflow, r.container.AuthMiddleware.GrantPermission(constants.VIEW_ONBOARDING)) g.GET("/workflows", r.container.OnboardingHandler.GetWorkflows, r.container.AuthMiddleware.GrantPermission(constants.VIEW_ONBOARDING)) g.GET("/workflows/:id", r.container.OnboardingHandler.GetWorkflowDetail, r.container.AuthMiddleware.GrantPermission(constants.VIEW_ONBOARDING)) // Tasks g.PUT("/tasks/:id/complete", r.container.OnboardingHandler.CompleteTask, r.container.AuthMiddleware.GrantPermission(constants.UPDATE_ONBOARDING_TASK)) + g.PUT("/workflows/:id/tasks", r.container.OnboardingHandler.UpdateWorkflowTasks, r.container.AuthMiddleware.GrantPermission(constants.VIEW_ONBOARDING)) } diff --git a/backend/internal/routes/subscription.go b/backend/internal/routes/subscription.go index e0178da..339bf52 100644 --- a/backend/internal/routes/subscription.go +++ b/backend/internal/routes/subscription.go @@ -19,5 +19,6 @@ func (r *Router) SetupSubscriptionAdminRoutes(e *echo.Group) { g.GET("/companies", r.container.SubscriptionHandler.ListCompanies) g.GET("/companies/:id", r.container.SubscriptionHandler.GetCompanyDetail) g.PUT("/companies/:id/status", r.container.SubscriptionHandler.UpdateCompanyStatus) + g.DELETE("/companies/:id/cache", r.container.SubscriptionHandler.RefreshCompanyCache) g.GET("/dashboard", r.container.SubscriptionHandler.GetDashboardStats) } diff --git a/backend/internal/seeder/execute.go b/backend/internal/seeder/execute.go index a64d425..0f53b9a 100644 --- a/backend/internal/seeder/execute.go +++ b/backend/internal/seeder/execute.go @@ -7,7 +7,6 @@ import ( "basekarya-backend/internal/config" "basekarya-backend/internal/modules/department" "basekarya-backend/internal/modules/master" - "basekarya-backend/internal/modules/onboarding" "basekarya-backend/internal/modules/rbac" "basekarya-backend/internal/modules/subscription" "basekarya-backend/internal/modules/user" @@ -36,10 +35,6 @@ func Execute(db *gorm.DB, cfg *config.Config, hasher Hasher) error { return err } - if err := seedOnboardingTemplates(tx); err != nil { - return err - } - return nil }) @@ -167,7 +162,7 @@ func seedPermissions(tx *gorm.DB, roleSuperadmin *rbac.Role) error { {"Announcement", []string{constants.CREATE_ANNOUNCEMENT}}, {"Contract", []string{constants.VIEW_CONTRACT, constants.CREATE_CONTRACT, constants.UPDATE_CONTRACT, constants.EXPORT_CONTRACT}}, {"Recruitment", []string{constants.VIEW_REQUISITION, constants.CREATE_REQUISITION, constants.APPROVAL_REQUISITION, constants.VIEW_APPLICANT, constants.CREATE_APPLICANT, constants.UPDATE_APPLICANT}}, - {"Onboarding", []string{constants.VIEW_ONBOARDING, constants.MANAGE_ONBOARDING_TEMPLATE, constants.UPDATE_ONBOARDING_TASK}}, + {"Onboarding", []string{constants.VIEW_ONBOARDING, constants.UPDATE_ONBOARDING_TASK}}, {"Finance", []string{constants.VIEW_FINANCE, constants.CREATE_FINANCE, constants.APPROVAL_FINANCE, constants.EXPORT_FINANCE, constants.MANAGE_FINANCE_CATEGORY, constants.VIEW_FINANCE_DASHBOARD}}, {"Asset", []string{constants.MANAGE_ASSET, constants.VIEW_ASSET, constants.VIEW_SELF_ASSET, constants.CREATE_ASSET, constants.APPROVAL_ASSET, constants.EXPORT_ASSET}}, } @@ -249,88 +244,3 @@ func formatDisplayName(name string) string { return strings.Join(words, " ") } -func seedOnboardingTemplates(tx *gorm.DB) error { - type templateDef struct { - Name string - Department string - Tasks []struct { - Name, Description string - Order int - } - } - - templates := []templateDef{ - { - Name: "IT Setup", - Department: "IT", - Tasks: []struct { - Name, Description string - Order int - }{ - {"Buat akun email perusahaan", "Buat akun email @company.com untuk karyawan baru", 1}, - {"Setup laptop/PC", "Siapkan laptop atau PC beserta perangkat yang dibutuhkan", 2}, - {"Berikan akses ke sistem internal", "Berikan akses ke aplikasi HR, project management, dan sistem internal lainnya", 3}, - {"Instalasi software wajib", "Install software wajib sesuai kebutuhan divisi", 4}, - }, - }, - { - Name: "HR Document Collection", - Department: "HR", - Tasks: []struct { - Name, Description string - Order int - }{ - {"Kumpulkan KTP", "Minta dan simpan salinan KTP karyawan baru", 1}, - {"Kumpulkan Kartu Keluarga (KK)", "Minta dan simpan salinan Kartu Keluarga", 2}, - {"Kumpulkan NPWP", "Minta dan simpan salinan NPWP", 3}, - {"Kumpulkan BPJS Kesehatan & Ketenagakerjaan", "Minta dan simpan kartu BPJS atau nomor kepesertaan", 4}, - {"Upload foto pas 3x4", "Minta foto resmi berukuran 3x4 untuk identitas karyawan", 5}, - {"Tanda tangan kontrak kerja", "Proses penandatanganan kontrak kerja", 6}, - }, - }, - } - - for _, def := range templates { - var existing onboarding.OnboardingTemplate - result := tx.Where("name = ? AND department = ?", def.Name, def.Department).First(&existing) - - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - tmpl := onboarding.OnboardingTemplate{ - Name: def.Name, - Department: def.Department, - CompanyID: 1, - } - for _, task := range def.Tasks { - tmpl.Items = append(tmpl.Items, onboarding.OnboardingTemplateItem{ - TaskName: task.Name, - Description: task.Description, - SortOrder: task.Order, - CompanyID: 1, - }) - } - if err := tx.Create(&tmpl).Error; err != nil { - return err - } - } else if result.Error != nil { - return result.Error - } else { - tx.Where("template_id = ?", existing.ID).Delete(&onboarding.OnboardingTemplateItem{}) - - var items []onboarding.OnboardingTemplateItem - for _, task := range def.Tasks { - items = append(items, onboarding.OnboardingTemplateItem{ - TemplateID: existing.ID, - TaskName: task.Name, - Description: task.Description, - SortOrder: task.Order, - CompanyID: 1, - }) - } - if err := tx.Create(&items).Error; err != nil { - return err - } - } - } - - return nil -} diff --git a/backend/migrations/000026_remove_onboarding_templates.down.sql b/backend/migrations/000026_remove_onboarding_templates.down.sql new file mode 100644 index 0000000..867a429 --- /dev/null +++ b/backend/migrations/000026_remove_onboarding_templates.down.sql @@ -0,0 +1,26 @@ +CREATE TABLE onboarding_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + department VARCHAR(50) NOT NULL, + company_id BIGINT NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_onboarding_templates_company_id (company_id), + INDEX idx_onboarding_templates_deleted_at (deleted_at) +); + +CREATE TABLE onboarding_template_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_id BIGINT NOT NULL, + task_name VARCHAR(255) NOT NULL, + description TEXT NULL, + sort_order INT DEFAULT 0, + company_id BIGINT NOT NULL DEFAULT 1, + INDEX idx_onboarding_template_items_company_id (company_id), + FOREIGN KEY (template_id) REFERENCES onboarding_templates(id) ON DELETE CASCADE +); + +ALTER TABLE onboarding_tasks ADD COLUMN template_item_id BIGINT NULL; +ALTER TABLE onboarding_tasks ADD COLUMN department VARCHAR(50) NOT NULL DEFAULT ''; +ALTER TABLE onboarding_tasks ADD FOREIGN KEY (template_item_id) REFERENCES onboarding_template_items(id) ON DELETE SET NULL; diff --git a/backend/migrations/000026_remove_onboarding_templates.up.sql b/backend/migrations/000026_remove_onboarding_templates.up.sql new file mode 100644 index 0000000..d54990a --- /dev/null +++ b/backend/migrations/000026_remove_onboarding_templates.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE onboarding_tasks DROP FOREIGN KEY onboarding_tasks_ibfk_2; +ALTER TABLE onboarding_tasks DROP COLUMN template_item_id; +ALTER TABLE onboarding_tasks DROP COLUMN department; +DROP TABLE IF EXISTS onboarding_template_items; +DROP TABLE IF EXISTS onboarding_templates; diff --git a/backend/pkg/constants/cache_key.go b/backend/pkg/constants/cache_key.go index 16d1768..86998de 100644 --- a/backend/pkg/constants/cache_key.go +++ b/backend/pkg/constants/cache_key.go @@ -8,5 +8,6 @@ const ( DEPARTMEN_CACHE_KEY = "department:all" SHIFT_CACHE_KEY = "shift:all" LEAVE_TYPE_CACHE_KEY = "leave_type:all" - COMPANY_PROFILE_CACHE_KEY = "company:profile:%d" + COMPANY_PROFILE_CACHE_KEY = "company:profile:%d" + SUBSCRIPTION_FEATURES_CACHE_KEY = "subscription:features:%d" ) diff --git a/backend/pkg/constants/permission_key.go b/backend/pkg/constants/permission_key.go index 4cc1b75..0009fba 100644 --- a/backend/pkg/constants/permission_key.go +++ b/backend/pkg/constants/permission_key.go @@ -85,9 +85,8 @@ const ( UPDATE_APPLICANT = "UPDATE_APPLICANT" // onboarding - VIEW_ONBOARDING = "VIEW_ONBOARDING" - MANAGE_ONBOARDING_TEMPLATE = "MANAGE_ONBOARDING_TEMPLATE" - UPDATE_ONBOARDING_TASK = "UPDATE_ONBOARDING_TASK" + VIEW_ONBOARDING = "VIEW_ONBOARDING" + UPDATE_ONBOARDING_TASK = "UPDATE_ONBOARDING_TASK" // finance VIEW_FINANCE = "VIEW_FINANCE" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 08326a9..1b46239 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,7 +36,6 @@ import ContractListPage from "@/pages/admin/ContractListPage"; import RequisitionListPage from "@/pages/admin/RequisitionListPage"; import ApplicantBoardPage from "@/pages/admin/ApplicantBoardPage"; import OnboardingListPage from "@/pages/admin/OnboardingListPage"; -import OnboardingTemplatePage from "@/pages/admin/OnboardingTemplatePage"; import SubscriptionAdminPage from "@/pages/admin/SubscriptionAdminPage"; import PlatformAdminDashboard from "@/pages/admin/PlatformAdminDashboard"; import CompaniesPage from "@/pages/admin/CompaniesPage"; @@ -177,7 +176,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/config/menu.ts b/frontend/src/config/menu.ts index bbcb4cb..633bc51 100644 --- a/frontend/src/config/menu.ts +++ b/frontend/src/config/menu.ts @@ -157,15 +157,6 @@ export const menuItems: MenuItem[] = [ hideForPlatformAdmin: true, requiredModule: "onboarding", }, - { - title: "Onboarding Templates", - href: "/admin/onboarding/templates", - icon: GraduationCap, - permission: PERMISSIONS.MANAGE_ONBOARDING_TEMPLATE, - group: "Rekrutmen", - hideForPlatformAdmin: true, - requiredModule: "onboarding", - }, { title: "Company Settings", diff --git a/frontend/src/features/finance/components/FinanceDashboard.tsx b/frontend/src/features/finance/components/FinanceDashboard.tsx index d97de71..382d5d8 100644 --- a/frontend/src/features/finance/components/FinanceDashboard.tsx +++ b/frontend/src/features/finance/components/FinanceDashboard.tsx @@ -2,7 +2,13 @@ import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Loader2, TrendingUp, TrendingDown, Wallet, Activity } from "lucide-react"; +import { + Loader2, + TrendingUp, + TrendingDown, + Wallet, + Activity, +} from "lucide-react"; import { useFinanceDashboard } from "@/features/finance/hooks/useFinanceDashboard"; import { Chart as ChartJS, @@ -16,15 +22,24 @@ import { } from "chart.js"; import { Bar, Doughnut } from "react-chartjs-2"; -ChartJS.register(CategoryScale, LinearScale, BarElement, ArcElement, Title, Tooltip, Legend); +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + ArcElement, + Title, + Tooltip, + Legend, +); export const FinanceDashboardView = () => { - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); + const today = new Date().toISOString().slice(0, 10); + const [startDate, setStartDate] = useState(today); + const [endDate, setEndDate] = useState(today); const { data, isLoading } = useFinanceDashboard( startDate || undefined, - endDate || undefined + endDate || undefined, ); const formatCurrency = (amount: number) => { @@ -41,8 +56,8 @@ export const FinanceDashboardView = () => { }; const handleReset = () => { - setStartDate(""); - setEndDate(""); + setStartDate(today); + setEndDate(today); }; const monthlyChartData = { @@ -68,10 +83,10 @@ export const FinanceDashboardView = () => { }; const incomeBreakdown = (data?.category_breakdown || []).filter( - (item) => item.type === "INCOME" + (item) => item.type === "INCOME", ); const expenseBreakdown = (data?.category_breakdown || []).filter( - (item) => item.type === "EXPENSE" + (item) => item.type === "EXPENSE", ); const incomeChartData = { @@ -159,7 +174,9 @@ export const FinanceDashboardView = () => {
- Total Pemasukan + + Total Pemasukan + @@ -171,7 +188,9 @@ export const FinanceDashboardView = () => { - Total Pengeluaran + + Total Pengeluaran + @@ -187,7 +206,9 @@ export const FinanceDashboardView = () => { -
= 0 ? "text-blue-600" : "text-red-600"}`}> +
= 0 ? "text-blue-600" : "text-red-600"}`} + > {formatCurrency(data?.net_balance || 0)}
@@ -195,7 +216,9 @@ export const FinanceDashboardView = () => { - Total Transaksi + + Total Transaksi + @@ -209,7 +232,9 @@ export const FinanceDashboardView = () => {
- Pemasukan vs Pengeluaran per Bulan + + Pemasukan vs Pengeluaran per Bulan + {(data?.monthly_summary || []).length > 0 ? ( @@ -253,7 +278,9 @@ export const FinanceDashboardView = () => { - Breakdown Pengeluaran per Kategori + + Breakdown Pengeluaran per Kategori + {expenseBreakdown.length > 0 ? ( @@ -287,7 +314,9 @@ export const FinanceDashboardView = () => {
- Breakdown Pemasukan per Kategori + + Breakdown Pemasukan per Kategori + {incomeBreakdown.length > 0 ? ( @@ -332,11 +361,14 @@ export const FinanceDashboardView = () => {

{tx.category_name}

- {new Date(tx.transaction_date).toLocaleDateString("id-ID", { - day: "numeric", - month: "short", - year: "numeric", - })} + {new Date(tx.transaction_date).toLocaleDateString( + "id-ID", + { + day: "numeric", + month: "short", + year: "numeric", + }, + )} {" - "} {tx.creator_name}

diff --git a/frontend/src/features/onboarding/components/CreateWorkflowDialog.tsx b/frontend/src/features/onboarding/components/CreateWorkflowDialog.tsx index a28d385..87f61c8 100644 --- a/frontend/src/features/onboarding/components/CreateWorkflowDialog.tsx +++ b/frontend/src/features/onboarding/components/CreateWorkflowDialog.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useFieldArray } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { @@ -18,16 +18,24 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; +import { Loader2, Plus, Trash2, GripVertical } from "lucide-react"; import { useCreateWorkflow } from "@/features/onboarding/hooks/useOnboarding"; +const itemSchema = z.object({ + task_name: z.string().min(2, "Task name required"), + description: z.string().optional().default(""), + sort_order: z.coerce.number().default(0), +}); + const schema = z.object({ new_hire_name: z.string().min(2, "Name required"), new_hire_email: z.string().email("Valid email required"), position: z.string().optional().default(""), department: z.string().optional().default(""), start_date: z.string().optional().default(""), + tasks: z.array(itemSchema).min(1, "At least one task required"), }); type FormValues = z.infer; @@ -49,9 +57,15 @@ export function CreateWorkflowDialog({ open, onOpenChange, applicantId }: Props) position: "", department: "", start_date: "", + tasks: [{ task_name: "", description: "", sort_order: 1 }], }, }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "tasks", + }); + useEffect(() => { if (!open) form.reset(); }, [open, form]); @@ -64,13 +78,18 @@ export function CreateWorkflowDialog({ open, onOpenChange, applicantId }: Props) position: values.position || undefined, department: values.department || undefined, start_date: values.start_date || undefined, + tasks: values.tasks.map((item, i) => ({ + task_name: item.task_name, + description: item.description ?? "", + sort_order: item.sort_order ?? i + 1, + })), }); onOpenChange(false); }; return ( - + Start Onboarding Workflow @@ -145,6 +164,68 @@ export function CreateWorkflowDialog({ open, onOpenChange, applicantId }: Props) )} /> +
+
+

Tasks

+ +
+ + {fields.map((field, index) => ( +
+ +
+ ( + + + + + + + )} + /> + ( + + +