Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6e916ab
feat: add migration to remove onboarding templates and department fro…
PickHD Jun 15, 2026
37eff2c
refactor(onboarding): remove templates and department-based task grou…
PickHD Jun 15, 2026
faa8da9
test(onboarding): update tests after removing templates and departmen…
PickHD Jun 15, 2026
a73012a
feat(onboarding): update frontend types and hooks for flat task model
PickHD Jun 15, 2026
01ea87d
feat(onboarding): add inline task builder to workflow form, flatten d…
PickHD Jun 15, 2026
358c8d6
feat(onboarding): add workflow task update endpoint and manage tasks UI
PickHD Jun 15, 2026
706d694
docs: add subscription redis caching design spec
PickHD Jun 15, 2026
de1aa57
docs: add subscription redis caching implementation plan
PickHD Jun 15, 2026
d992c48
chore: remove unused plans & specs
PickHD Jun 15, 2026
f6560b2
feat: add SUBSCRIPTION_FEATURES_CACHE_KEY constant
PickHD Jun 15, 2026
b570ed9
feat: add PlanCacheService with Redis caching for subscription features
PickHD Jun 15, 2026
ea1ff7e
fix: improve error handling in PlanCacheService
PickHD Jun 15, 2026
aa72126
feat: replace FlushDB with Del on CacheProvider interface
PickHD Jun 15, 2026
57ef9e6
feat: replace FlushDB with targeted Del invalidation in subscription …
PickHD Jun 15, 2026
d6d00c3
refactor: inject ModuleAccessProvider into SubscriptionMiddleware
PickHD Jun 15, 2026
7378081
test: add CheckEmployeeLimit delegation test for SubscriptionMiddleware
PickHD Jun 15, 2026
2a82a99
feat: add FindExpiredCompanies to subscription repository
PickHD Jun 15, 2026
dd60aab
fix: use WithContext in FindExpiredCompanies
PickHD Jun 15, 2026
7bbdf82
feat: add subscription expiry scheduler
PickHD Jun 15, 2026
70664e0
feat: add admin company cache refresh endpoint
PickHD Jun 15, 2026
1b34260
fix: remove dead error branch in RefreshCompanyCache handler
PickHD Jun 15, 2026
139cebd
feat: wire PlanCacheService into container, start subscription scheduler
PickHD Jun 15, 2026
6491d21
test: add FindExpiredCompanies and RefreshCompanyCache mocks
PickHD Jun 15, 2026
94125d7
Merge branch 'enhancement/onboard-module' of https://github.com/PickH…
PickHD Jun 15, 2026
c55d994
refactor: improve code formatting and readability in FinanceDashboard…
PickHD Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env
frontend/node_modules
frontend/coverage
backend/coverage.out
backend/coverage.out
docs
1 change: 1 addition & 0 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand Down
16 changes: 12 additions & 4 deletions backend/internal/bootstrap/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -203,7 +206,8 @@ func NewContainer() (*Container, error) {
GeocodeWorker: geocodeWorker,
LeaveScheduler: leaveScheduler,
NotificationScheduler: notificationScheduler,
ContractScheduler: contractScheduler,
ContractScheduler: contractScheduler,
SubscriptionScheduler: subscriptionScheduler,
}, nil
}

Expand All @@ -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()
}
Expand Down
74 changes: 18 additions & 56 deletions backend/internal/middleware/subscription.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}
81 changes: 51 additions & 30 deletions backend/internal/middleware/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)
}
63 changes: 19 additions & 44 deletions backend/internal/modules/onboarding/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand Down
Loading
Loading