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() {
{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