forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
@@ -91,11 +91,6 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"pre-cast.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
tenantType: domain.TenantTypePersonal,
|
||||
},
|
||||
}
|
||||
|
||||
if len(configs) < len(expected) {
|
||||
@@ -161,6 +156,42 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedTenantCSVDefinesTopLevelSeedTenantStructure(t *testing.T) {
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||
}
|
||||
|
||||
configBySlug := make(map[string]InitialTenantConfig, len(configs))
|
||||
for _, config := range configs {
|
||||
configBySlug[config.Slug] = config
|
||||
}
|
||||
|
||||
expectedRoots := []struct {
|
||||
slug string
|
||||
tenantType string
|
||||
}{
|
||||
{slug: "hanmac-family", tenantType: domain.TenantTypeCompanyGroup},
|
||||
{slug: "commercial", tenantType: domain.TenantTypeCompanyGroup},
|
||||
{slug: "public-org", tenantType: domain.TenantTypeCompanyGroup},
|
||||
{slug: "edu", tenantType: domain.TenantTypeCompanyGroup},
|
||||
{slug: "personal", tenantType: domain.TenantTypePersonal},
|
||||
}
|
||||
|
||||
for _, want := range expectedRoots {
|
||||
got, ok := configBySlug[want.slug]
|
||||
if !ok {
|
||||
t.Fatalf("top-level seed tenant slug %q not found", want.slug)
|
||||
}
|
||||
if got.Type != want.tenantType {
|
||||
t.Fatalf("top-level seed tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
|
||||
}
|
||||
if got.ParentSlug != "" {
|
||||
t.Fatalf("top-level seed tenant[%s] parent slug = %q, want empty", want.slug, got.ParentSlug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
|
||||
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
|
||||
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
|
||||
|
||||
@@ -8038,11 +8038,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
|
||||
if department := extractTraitString(traits, "department"); department != "" {
|
||||
localUser.Department = department
|
||||
}
|
||||
if grade := extractTraitString(traits, "grade"); grade != "" {
|
||||
if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
|
||||
localUser.Grade = grade
|
||||
}
|
||||
}
|
||||
if position := extractTraitString(traits, "position"); position != "" {
|
||||
localUser.Position = position
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -66,6 +67,16 @@ func seedTenantSlugsForDeleteGuard() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func tenantCreatorIDForMembership(profile *domain.UserProfileResponse) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(profile.ID)
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
DB: db,
|
||||
@@ -125,6 +136,20 @@ type tenantListResponse struct {
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type tenantSortDirection string
|
||||
|
||||
const (
|
||||
tenantSortAsc tenantSortDirection = "asc"
|
||||
tenantSortDesc tenantSortDirection = "desc"
|
||||
)
|
||||
|
||||
type tenantQueryCursor struct {
|
||||
Sort string `json:"sort"`
|
||||
Direction string `json:"direction"`
|
||||
Value string `json:"value"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type orgChartSnapshotCacheInfo struct {
|
||||
Source string `json:"source"`
|
||||
Hit bool `json:"hit"`
|
||||
@@ -132,9 +157,10 @@ type orgChartSnapshotCacheInfo struct {
|
||||
}
|
||||
|
||||
type orgChartSnapshotResponse struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
Cache orgChartSnapshotCacheInfo `json:"cache"`
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Cache orgChartSnapshotCacheInfo `json:"cache"`
|
||||
}
|
||||
|
||||
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
|
||||
@@ -147,6 +173,189 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) (
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeTenantSortDirection(raw string) tenantSortDirection {
|
||||
if strings.EqualFold(strings.TrimSpace(raw), string(tenantSortAsc)) {
|
||||
return tenantSortAsc
|
||||
}
|
||||
return tenantSortDesc
|
||||
}
|
||||
|
||||
func normalizeTenantSortKey(raw string) string {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "id", "name", "slug", "type", "status", "createdAt", "updatedAt":
|
||||
return strings.TrimSpace(raw)
|
||||
default:
|
||||
return "createdAt"
|
||||
}
|
||||
}
|
||||
|
||||
func encodeTenantQueryCursor(sortKey string, direction tenantSortDirection, value string, id string) string {
|
||||
if strings.TrimSpace(value) == "" || strings.TrimSpace(id) == "" {
|
||||
return ""
|
||||
}
|
||||
payload, err := json.Marshal(tenantQueryCursor{
|
||||
Sort: sortKey,
|
||||
Direction: string(direction),
|
||||
Value: value,
|
||||
ID: id,
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func decodeTenantQueryCursor(raw string, sortKey string, direction tenantSortDirection) (*tenantQueryCursor, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cursor tenantQueryCursor
|
||||
if err := json.Unmarshal(decoded, &cursor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cursor.ID) == "" || strings.TrimSpace(cursor.Value) == "" {
|
||||
return nil, errors.New("invalid tenant cursor")
|
||||
}
|
||||
if cursor.Sort != sortKey || cursor.Direction != string(direction) {
|
||||
return nil, errors.New("tenant cursor does not match sort")
|
||||
}
|
||||
return &cursor, nil
|
||||
}
|
||||
|
||||
func tenantCursorValue(tenant domain.Tenant, sortKey string) string {
|
||||
switch sortKey {
|
||||
case "id":
|
||||
return tenant.ID
|
||||
case "name":
|
||||
return strings.ToLower(tenant.Name)
|
||||
case "slug":
|
||||
return strings.ToLower(tenant.Slug)
|
||||
case "type":
|
||||
return strings.ToLower(tenant.Type)
|
||||
case "status":
|
||||
return strings.ToLower(tenant.Status)
|
||||
case "updatedAt":
|
||||
return tenant.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
return tenant.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
|
||||
func filterTenantsByListSearch(tenants []domain.Tenant, search string) []domain.Tenant {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
if search == "" {
|
||||
return tenants
|
||||
}
|
||||
filtered := make([]domain.Tenant, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
if strings.Contains(strings.ToLower(tenant.ID), search) ||
|
||||
strings.Contains(strings.ToLower(tenant.Name), search) ||
|
||||
strings.Contains(strings.ToLower(tenant.Slug), search) ||
|
||||
strings.Contains(strings.ToLower(tenant.Type), search) ||
|
||||
strings.Contains(strings.ToLower(tenant.Description), search) {
|
||||
filtered = append(filtered, tenant)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func sortTenantsForList(tenants []domain.Tenant, sortKey string, direction tenantSortDirection) {
|
||||
sort.SliceStable(tenants, func(i, j int) bool {
|
||||
left := tenantCursorValue(tenants[i], sortKey)
|
||||
right := tenantCursorValue(tenants[j], sortKey)
|
||||
if left == right {
|
||||
if direction == tenantSortAsc {
|
||||
return tenants[i].ID < tenants[j].ID
|
||||
}
|
||||
return tenants[i].ID > tenants[j].ID
|
||||
}
|
||||
if direction == tenantSortAsc {
|
||||
return left < right
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
}
|
||||
|
||||
func pageSortedTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, string, error) {
|
||||
ordered := append([]domain.Tenant(nil), tenants...)
|
||||
sortTenantsForList(ordered, sortKey, direction)
|
||||
|
||||
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if cursor != nil {
|
||||
filtered := ordered[:0]
|
||||
for _, tenant := range ordered {
|
||||
value := tenantCursorValue(tenant, sortKey)
|
||||
after := value > cursor.Value || (value == cursor.Value && tenant.ID > cursor.ID)
|
||||
if direction == tenantSortDesc {
|
||||
after = value < cursor.Value || (value == cursor.Value && tenant.ID < cursor.ID)
|
||||
}
|
||||
if after {
|
||||
filtered = append(filtered, tenant)
|
||||
}
|
||||
}
|
||||
ordered = filtered
|
||||
}
|
||||
|
||||
if len(ordered) <= limit {
|
||||
return ordered, "", nil
|
||||
}
|
||||
page := ordered[:limit]
|
||||
last := page[len(page)-1]
|
||||
return page, encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID), nil
|
||||
}
|
||||
|
||||
func tenantSortExpression(sortKey string) string {
|
||||
switch sortKey {
|
||||
case "id":
|
||||
return "id::text"
|
||||
case "name":
|
||||
return "LOWER(name)"
|
||||
case "slug":
|
||||
return "LOWER(slug)"
|
||||
case "type":
|
||||
return "LOWER(type)"
|
||||
case "status":
|
||||
return "LOWER(status)"
|
||||
case "updatedAt":
|
||||
return "updated_at"
|
||||
default:
|
||||
return "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
func tenantOrderClause(sortExpression string, direction tenantSortDirection) string {
|
||||
if direction == tenantSortAsc {
|
||||
return sortExpression + " asc, id asc"
|
||||
}
|
||||
return sortExpression + " desc, id desc"
|
||||
}
|
||||
|
||||
func applyTenantQueryCursor(db *gorm.DB, sortExpression string, cursor *tenantQueryCursor, direction tenantSortDirection) *gorm.DB {
|
||||
if cursor == nil {
|
||||
return db
|
||||
}
|
||||
operator := "<"
|
||||
idOperator := "<"
|
||||
if direction == tenantSortAsc {
|
||||
operator = ">"
|
||||
idOperator = ">"
|
||||
}
|
||||
return db.Where(
|
||||
fmt.Sprintf("%s %s ? OR (%s = ? AND id::text %s ?)", sortExpression, operator, sortExpression, idOperator),
|
||||
cursor.Value,
|
||||
cursor.Value,
|
||||
cursor.ID,
|
||||
)
|
||||
}
|
||||
|
||||
type tenantImportDetail struct {
|
||||
Row int `json:"row"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -289,6 +498,9 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
offset := c.QueryInt("offset", 0)
|
||||
parentId := c.Query("parentId")
|
||||
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
sortKey := normalizeTenantSortKey(c.Query("sort"))
|
||||
sortDirection := normalizeTenantSortDirection(c.Query("direction"))
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
@@ -298,6 +510,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var tenants []domain.Tenant
|
||||
var countSourceTenants []domain.Tenant
|
||||
var total int64
|
||||
var err error
|
||||
nextCursor := ""
|
||||
@@ -365,44 +578,55 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
|
||||
tenants = filterTenantsByListSearch(tenants, search)
|
||||
countSourceTenants = append([]domain.Tenant(nil), tenants...)
|
||||
total = int64(len(tenants))
|
||||
if cursorRaw != "" {
|
||||
tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
|
||||
tenants, nextCursor, err = pageSortedTenantsByCursor(tenants, limit, cursorRaw, sortKey, sortDirection)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
offset = 0
|
||||
} else if offset < len(tenants) {
|
||||
sortTenantsForList(tenants, sortKey, sortDirection)
|
||||
end := min(offset+limit, len(tenants))
|
||||
tenants = tenants[offset:end]
|
||||
if total > int64(end) && len(tenants) > 0 {
|
||||
last := tenants[len(tenants)-1]
|
||||
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
||||
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
|
||||
}
|
||||
} else {
|
||||
tenants = []domain.Tenant{}
|
||||
}
|
||||
} else {
|
||||
// Super Admin case
|
||||
if cursorRaw != "" && h.DB != nil {
|
||||
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
|
||||
if h.DB != nil {
|
||||
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, offset, parentId, cursorRaw, search, sortKey, sortDirection)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
offset = 0
|
||||
if cursorRaw != "" {
|
||||
offset = 0
|
||||
}
|
||||
} else {
|
||||
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
|
||||
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, search)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if total > int64(offset+len(tenants)) && len(tenants) > 0 {
|
||||
last := tenants[len(tenants)-1]
|
||||
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
||||
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
|
||||
var memberCounts map[string]int64
|
||||
var totalMemberCounts map[string]int64
|
||||
if countSourceTenants != nil {
|
||||
memberCounts, totalMemberCounts, err = h.countTenantMembersFromTenantSet(c.Context(), tenants, countSourceTenants)
|
||||
} else {
|
||||
memberCounts, totalMemberCounts, err = h.countTenantMembers(c.Context(), tenants)
|
||||
}
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
@@ -425,8 +649,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
|
||||
cursor, err := pagination.Decode(cursorRaw)
|
||||
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, offset int, parentID string, cursorRaw string, search string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, int64, string, error) {
|
||||
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
@@ -440,8 +664,9 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + strings.ToLower(search) + "%"
|
||||
countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
|
||||
pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
|
||||
searchClause := "LOWER(id::text) LIKE ? OR LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(type) LIKE ? OR LOWER(description) LIKE ?"
|
||||
countQuery = countQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
pageQuery = pageQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
var total int64
|
||||
@@ -449,11 +674,16 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
|
||||
sortExpression := tenantSortExpression(sortKey)
|
||||
if cursor != nil {
|
||||
pageQuery = applyTenantQueryCursor(pageQuery, sortExpression, cursor, direction)
|
||||
} else if offset > 0 {
|
||||
pageQuery = pageQuery.Offset(offset)
|
||||
}
|
||||
|
||||
var tenants []domain.Tenant
|
||||
if err := pageQuery.
|
||||
Order("created_at desc, id desc").
|
||||
Order(tenantOrderClause(sortExpression, direction)).
|
||||
Limit(limit + 1).
|
||||
Preload("Domains").
|
||||
Find(&tenants).Error; err != nil {
|
||||
@@ -464,7 +694,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
|
||||
if len(tenants) > limit {
|
||||
tenants = tenants[:limit]
|
||||
last := tenants[len(tenants)-1]
|
||||
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
||||
nextCursor = encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID)
|
||||
}
|
||||
return tenants, total, nextCursor, nil
|
||||
}
|
||||
@@ -609,8 +839,8 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
||||
records = orderTenantCSVRecordsByParentSlug(records)
|
||||
|
||||
creatorID := ""
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||
creatorID = profile.ID
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
||||
creatorID = tenantCreatorIDForMembership(profile)
|
||||
}
|
||||
|
||||
tenantIDBySlug := make(map[string]string)
|
||||
@@ -1861,10 +2091,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
parentID = &pid
|
||||
}
|
||||
|
||||
// Extract creator ID if present
|
||||
creatorID := ""
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
||||
creatorID = profile.ID
|
||||
creatorID = tenantCreatorIDForMembership(profile)
|
||||
}
|
||||
|
||||
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
|
||||
@@ -2887,9 +3116,6 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
|
||||
includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true")
|
||||
membersByTenantID := make(map[string][]orgContextMember)
|
||||
if includeUsers {
|
||||
if h.UserRepo == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
|
||||
}
|
||||
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -2919,32 +3145,22 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) {
|
||||
usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
|
||||
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
membersByTenantID := make(map[string][]orgContextMember)
|
||||
users := append(usersByID, usersBySlug...)
|
||||
users = append(users, usersByAppointment...)
|
||||
for _, user := range users {
|
||||
if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
|
||||
for _, identity := range identities {
|
||||
if seen[identity.ID] || !domain.IsOrgVisibleUserStatus(normalizeStatus(identity.State)) {
|
||||
continue
|
||||
}
|
||||
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
|
||||
assignments := mapOrgContextIdentityAssignments(identity, tenantByID, tenantBySlug, includeUserIDs)
|
||||
if len(assignments) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[user.ID] = true
|
||||
seen[identity.ID] = true
|
||||
for _, assignment := range assignments {
|
||||
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
|
||||
}
|
||||
@@ -2952,6 +3168,55 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
|
||||
return membersByTenantID, nil
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listOrgContextIdentities(ctx context.Context, tenantIDs, tenantSlugs []string) ([]service.KratosIdentity, error) {
|
||||
allowedTenantKeys := make(map[string]bool, len(tenantIDs)+len(tenantSlugs))
|
||||
for _, value := range append(tenantIDs, tenantSlugs...) {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key != "" {
|
||||
allowedTenantKeys[key] = true
|
||||
}
|
||||
}
|
||||
query := service.IdentityMirrorPageQuery{
|
||||
Limit: 10000,
|
||||
AllowedTenantKeys: allowedTenantKeys,
|
||||
}
|
||||
if h != nil && h.IdentityCache != nil {
|
||||
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.orgContextIdentityMirrorReady(ctx) {
|
||||
result, err := lister.ListIdentityMirrorPage(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Items, nil
|
||||
}
|
||||
}
|
||||
if h == nil || h.KratosAdmin == nil {
|
||||
return nil, errors.New("identity mirror is unavailable")
|
||||
}
|
||||
identities, err := h.KratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := pageIdentityMirrorSlice(identities, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Items, nil
|
||||
}
|
||||
|
||||
func (h *TenantHandler) orgContextIdentityMirrorReady(ctx context.Context) bool {
|
||||
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
status, err := reader.GetIdentityCacheStatus(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return status.RedisReady &&
|
||||
status.Status == "ready" &&
|
||||
status.MirrorVersion == identityMirrorVersion
|
||||
}
|
||||
|
||||
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(slug))
|
||||
for _, tenant := range tenants {
|
||||
@@ -3086,8 +3351,55 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
|
||||
return assignments
|
||||
}
|
||||
|
||||
func mapOrgContextIdentityAssignments(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
|
||||
assignments := make([]orgContextMemberAssignment, 0, 2)
|
||||
seenTenants := map[string]bool{}
|
||||
traits := identity.Traits
|
||||
appointments := tenantClaimAppointmentsFromTraits(traits)
|
||||
|
||||
addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) {
|
||||
if !ok || seenTenants[tenant.ID] {
|
||||
return
|
||||
}
|
||||
seenTenants[tenant.ID] = true
|
||||
if appointment == nil {
|
||||
appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{
|
||||
ID: tenant.ID,
|
||||
Slug: tenant.Slug,
|
||||
})
|
||||
}
|
||||
assignments = append(assignments, orgContextMemberAssignment{
|
||||
TenantID: tenant.ID,
|
||||
Member: mapOrgContextIdentityMember(identity, appointment, includeUserIDs),
|
||||
})
|
||||
}
|
||||
|
||||
for _, appointment := range appointments {
|
||||
for _, key := range []string{"tenantId", "tenant_id"} {
|
||||
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
|
||||
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
|
||||
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
|
||||
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
|
||||
addTenant(tenant, tenant.ID != "", appointment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
|
||||
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", nil)
|
||||
}
|
||||
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
|
||||
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
|
||||
addTenant(tenant, tenant.ID != "", nil)
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
|
||||
grade := user.Grade
|
||||
grade := ""
|
||||
position := user.Position
|
||||
jobTitle := user.JobTitle
|
||||
department := user.Department
|
||||
@@ -3139,6 +3451,60 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
|
||||
}
|
||||
}
|
||||
|
||||
func mapOrgContextIdentityMember(identity service.KratosIdentity, appointment map[string]any, includeUserIDs bool) orgContextMember {
|
||||
traits := identity.Traits
|
||||
grade := ""
|
||||
position := extractTraitString(traits, "position")
|
||||
jobTitle := extractTraitString(traits, "jobTitle")
|
||||
department := extractTraitString(traits, "department")
|
||||
if value := tenantClaimString(appointment, "grade"); value != "" {
|
||||
grade = value
|
||||
}
|
||||
if value := tenantClaimString(appointment, "position"); value != "" {
|
||||
position = value
|
||||
}
|
||||
if value := tenantClaimString(appointment, "jobTitle"); value != "" {
|
||||
jobTitle = value
|
||||
}
|
||||
if value := tenantClaimString(appointment, "job_title"); value != "" {
|
||||
jobTitle = value
|
||||
}
|
||||
if value := tenantClaimString(appointment, "department"); value != "" {
|
||||
department = value
|
||||
}
|
||||
isOwner := false
|
||||
if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok {
|
||||
isOwner = value
|
||||
}
|
||||
isManager := false
|
||||
if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok {
|
||||
isManager = value
|
||||
}
|
||||
isPrimary := false
|
||||
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
|
||||
isPrimary = value
|
||||
}
|
||||
id := ""
|
||||
phone := ""
|
||||
if includeUserIDs {
|
||||
id = identity.ID
|
||||
phone = extractTraitString(traits, "phone_number")
|
||||
}
|
||||
return orgContextMember{
|
||||
ID: id,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: phone,
|
||||
Department: department,
|
||||
Grade: grade,
|
||||
Position: position,
|
||||
JobTitle: jobTitle,
|
||||
IsOwner: isOwner,
|
||||
IsManager: isManager,
|
||||
IsPrimary: isPrimary,
|
||||
}
|
||||
}
|
||||
|
||||
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode {
|
||||
childrenByParentID := make(map[string][]domain.Tenant)
|
||||
for _, tenant := range tenants {
|
||||
@@ -3170,6 +3536,24 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
|
||||
}
|
||||
|
||||
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
|
||||
allTenants := tenants
|
||||
if h.DB != nil {
|
||||
var edges []domain.Tenant
|
||||
if err := h.DB.WithContext(ctx).
|
||||
Model(&domain.Tenant{}).
|
||||
Select("id", "parent_id").
|
||||
Find(&edges).Error; err == nil && len(edges) > 0 {
|
||||
allTenants = edges
|
||||
}
|
||||
} else if h.Service != nil {
|
||||
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
|
||||
allTenants = listed
|
||||
}
|
||||
}
|
||||
return h.countTenantMembersFromTenantSet(ctx, tenants, allTenants)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) countTenantMembersFromTenantSet(ctx context.Context, tenants []domain.Tenant, allTenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = 0
|
||||
@@ -3181,25 +3565,22 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
|
||||
return counts, counts, nil
|
||||
}
|
||||
|
||||
tenantIDs := make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
totalCounts := make(map[string]int64, len(tenants))
|
||||
allTenantIDs := make([]string, 0, len(allTenants))
|
||||
for _, tenant := range allTenants {
|
||||
if strings.TrimSpace(tenant.ID) != "" {
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
allTenantIDs = append(allTenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
|
||||
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
|
||||
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, allTenantIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
totalCounts := make(map[string]int64, len(tenants))
|
||||
allTenants := tenants
|
||||
if h.Service != nil {
|
||||
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
|
||||
allTenants = listed
|
||||
}
|
||||
for _, tenant := range tenants {
|
||||
counts[tenant.ID] = directCounts[tenant.ID]
|
||||
}
|
||||
|
||||
childrenByParentID := make(map[string][]domain.Tenant)
|
||||
for _, tenant := range allTenants {
|
||||
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
|
||||
@@ -3209,13 +3590,9 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
|
||||
}
|
||||
for _, tenant := range tenants {
|
||||
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
|
||||
if len(descendantIDs) == 0 {
|
||||
totalCounts[tenant.ID] = directCounts[tenant.ID]
|
||||
continue
|
||||
}
|
||||
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
|
||||
if countErr != nil {
|
||||
return nil, nil, countErr
|
||||
var total int64
|
||||
for _, descendantID := range descendantIDs {
|
||||
total += directCounts[descendantID]
|
||||
}
|
||||
totalCounts[tenant.ID] = total
|
||||
}
|
||||
@@ -3309,6 +3686,7 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
|
||||
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||
refreshRequested := parseBoolQuery(c.Query("refresh"))
|
||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||
role, userID, profileTenantID := orgChartProfileLogValues(profile)
|
||||
slog.Info("orgchart snapshot request started",
|
||||
@@ -3317,12 +3695,16 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"cache_mode", cacheMode,
|
||||
"refresh", refreshRequested,
|
||||
)
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil && !refreshRequested {
|
||||
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
var cached orgChartSnapshotResponse
|
||||
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
|
||||
if strings.TrimSpace(cached.GeneratedAt) == "" {
|
||||
cached.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
cached.Cache = orgChartSnapshotCacheInfo{
|
||||
Source: "redis",
|
||||
Hit: true,
|
||||
@@ -3384,7 +3766,11 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "MISS")
|
||||
if refreshRequested {
|
||||
c.Set("X-Orgfront-Cache", "REFRESH")
|
||||
} else {
|
||||
c.Set("X-Orgfront-Cache", "MISS")
|
||||
}
|
||||
} else {
|
||||
c.Set("X-Orgfront-Cache", "BYPASS")
|
||||
}
|
||||
@@ -3482,8 +3868,9 @@ func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *doma
|
||||
}
|
||||
|
||||
return orgChartSnapshotResponse{
|
||||
Tenants: tenantSummaries,
|
||||
Users: users,
|
||||
Tenants: tenantSummaries,
|
||||
Users: users,
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3543,57 +3930,133 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
|
||||
}
|
||||
|
||||
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
|
||||
if h.UserRepo == nil {
|
||||
return nil, errors.New("user repository is not configured")
|
||||
}
|
||||
role := ""
|
||||
if profile != nil {
|
||||
role = domain.NormalizeRole(profile.Role)
|
||||
}
|
||||
tenantIDs := []string{}
|
||||
tenantSlugs := []string{}
|
||||
if role != domain.RoleSuperAdmin {
|
||||
tenantIDs = make([]string, 0, len(tenants))
|
||||
tenantSlugs = make([]string, 0, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
tenantSlugs = append(tenantSlugs, tenant.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
|
||||
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summaries := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
summary := userSummary{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
LoginID: user.Email,
|
||||
Name: user.Name,
|
||||
Phone: user.Phone,
|
||||
Role: domain.NormalizeRole(user.Role),
|
||||
Status: normalizeStatus(user.Status),
|
||||
TenantSlug: userTenantSlug(user),
|
||||
CompanyCode: userTenantSlug(user),
|
||||
Metadata: user.Metadata,
|
||||
Tenant: user.Tenant,
|
||||
Department: user.Department,
|
||||
Grade: user.Grade,
|
||||
Position: user.Position,
|
||||
JobTitle: user.JobTitle,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
UpdatedAt: formatTime(user.UpdatedAt),
|
||||
}
|
||||
if h.Service != nil {
|
||||
if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
tenantByID := make(map[string]domain.Tenant, len(tenants))
|
||||
tenantBySlug := make(map[string]domain.Tenant, len(tenants))
|
||||
for _, tenant := range tenants {
|
||||
tenantByID[tenant.ID] = tenant
|
||||
tenantBySlug[strings.ToLower(tenant.Slug)] = tenant
|
||||
}
|
||||
|
||||
summaries := make([]userSummary, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
summaries = append(summaries, mapOrgChartIdentitySummary(identity, tenantByID, tenantBySlug))
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func mapOrgChartIdentitySummary(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]domain.Tenant) userSummary {
|
||||
traits := identity.Traits
|
||||
tenantID := extractTraitString(traits, "tenant_id")
|
||||
tenantSlug := extractTraitString(traits, "tenantSlug")
|
||||
var tenantSummary *domain.Tenant
|
||||
if tenantID != "" {
|
||||
if tenant, ok := tenantByID[tenantID]; ok {
|
||||
tenantCopy := tenant
|
||||
tenantSummary = &tenantCopy
|
||||
if tenantSlug == "" {
|
||||
tenantSlug = tenant.Slug
|
||||
}
|
||||
}
|
||||
}
|
||||
if tenantSummary == nil && tenantSlug != "" {
|
||||
if tenant, ok := tenantBySlug[strings.ToLower(tenantSlug)]; ok {
|
||||
tenantCopy := tenant
|
||||
tenantSummary = &tenantCopy
|
||||
if tenantID == "" {
|
||||
tenantID = tenant.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metadata := make(domain.JSONMap)
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true, "tenantSlug": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
for key, value := range traits {
|
||||
if !coreTraits[key] {
|
||||
metadata[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return userSummary{
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
LoginID: resolvePasswordLoginID(traits),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: roleFromTraits(traits),
|
||||
Status: normalizeStatus(identity.State),
|
||||
TenantSlug: tenantSlug,
|
||||
CompanyCode: tenantSlug,
|
||||
Metadata: metadata,
|
||||
Tenant: tenantSummary,
|
||||
JoinedTenants: orgChartIdentityJoinedTenants(traits, tenantByID, tenantBySlug),
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Grade: gradeFromTraits(traits),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||
CreatedAt: formatTime(identity.CreatedAt),
|
||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func orgChartIdentityJoinedTenants(traits map[string]any, tenantByID, tenantBySlug map[string]domain.Tenant) []domain.Tenant {
|
||||
seen := make(map[string]bool)
|
||||
joined := make([]domain.Tenant, 0, 2)
|
||||
addTenant := func(tenant domain.Tenant, ok bool) {
|
||||
if !ok || strings.TrimSpace(tenant.ID) == "" || seen[tenant.ID] {
|
||||
return
|
||||
}
|
||||
seen[tenant.ID] = true
|
||||
joined = append(joined, tenant)
|
||||
}
|
||||
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
|
||||
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
|
||||
}
|
||||
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
|
||||
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
|
||||
addTenant(tenant, tenant.ID != "")
|
||||
}
|
||||
for _, appointment := range tenantClaimAppointmentsFromTraits(traits) {
|
||||
for _, key := range []string{"tenantId", "tenant_id"} {
|
||||
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
|
||||
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
|
||||
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
|
||||
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
|
||||
addTenant(tenant, tenant.ID != "")
|
||||
}
|
||||
}
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
|
||||
role := "anonymous"
|
||||
userID := "anonymous"
|
||||
@@ -3651,6 +4114,15 @@ func orgChartSnapshotCacheExpiration() time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseBoolQuery(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
|
||||
@@ -106,6 +106,7 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN
|
||||
type MockUserRepoForHandler struct {
|
||||
mock.Mock
|
||||
deletedIDs []string
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) DB() *gorm.DB {
|
||||
@@ -145,6 +146,7 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
m.listCalls += 1
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "List" {
|
||||
args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
|
||||
@@ -240,6 +242,53 @@ func toJSONString(t *testing.T, value any) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func newReadyIdentityMirror(t *testing.T, now time.Time, identities ...service.KratosIdentity) *identityMirrorRedisStub {
|
||||
t.Helper()
|
||||
data := make(map[string]string, len(identities)+1)
|
||||
for _, identity := range identities {
|
||||
raw, err := json.Marshal(identity)
|
||||
require.NoError(t, err)
|
||||
data[identityMirrorKey(identity.ID)] = string(raw)
|
||||
}
|
||||
rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
|
||||
RedisReady: true,
|
||||
Status: "ready",
|
||||
ObservedCount: int64(len(identities)),
|
||||
MirrorVersion: identityMirrorVersion,
|
||||
LastRefreshedAt: &now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
data["identity:mirror:state"] = string(rawStatus)
|
||||
return &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: data}}
|
||||
}
|
||||
|
||||
func orgContextIdentityFromUserFixture(user domain.User) service.KratosIdentity {
|
||||
traits := map[string]any{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"phone_number": user.Phone,
|
||||
"department": user.Department,
|
||||
"position": user.Position,
|
||||
"jobTitle": user.JobTitle,
|
||||
}
|
||||
if user.TenantID != nil {
|
||||
traits["tenant_id"] = *user.TenantID
|
||||
}
|
||||
if strings.TrimSpace(user.CompanyCode) != "" {
|
||||
traits["tenantSlug"] = strings.TrimSpace(user.CompanyCode)
|
||||
}
|
||||
for key, value := range user.Metadata {
|
||||
traits[key] = value
|
||||
}
|
||||
return service.KratosIdentity{
|
||||
ID: user.ID,
|
||||
State: user.Status,
|
||||
Traits: traits,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -268,6 +317,43 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
assert.Equal(t, "t1", got["id"])
|
||||
}
|
||||
|
||||
func TestTenantHandler_CreateTenantDoesNotAssignSuperAdminAsCreatorMember(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/tenants", h.CreateTenant)
|
||||
|
||||
input := map[string]any{
|
||||
"name": "System Created Tenant",
|
||||
"slug": "system-created-tenant",
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
|
||||
mockSvc.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
"System Created Tenant",
|
||||
"system-created-tenant",
|
||||
domain.TenantTypeCompany,
|
||||
"",
|
||||
[]string(nil),
|
||||
(*string)(nil),
|
||||
"",
|
||||
).Return(&domain.Tenant{ID: "system-created-id", Name: "System Created Tenant", Slug: "system-created-tenant"}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -293,8 +379,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
|
||||
Return([]domain.User{}, int64(7), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -306,7 +390,60 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
|
||||
|
||||
require.Len(t, res.Items, 1)
|
||||
assert.Equal(t, int64(2), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
|
||||
assert.Equal(t, int64(2), res.Items[0].TotalMemberCount)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsPassesSearchToBackendQuery(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: "super_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
tenants := []domain.Tenant{{ID: "tenant-1", Name: "Saman", Slug: "saman"}}
|
||||
mockSvc.On("ListTenants", mock.Anything, 25, 0, "", "saman").Return(tenants, int64(1), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"tenant-1"}).
|
||||
Return(map[string]int64{"tenant-1": 1}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=25&search=saman", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_CountTenantMembersDoesNotListUsersPerTenant(t *testing.T) {
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
|
||||
|
||||
parentID := "parent-tenant"
|
||||
childID := "child-tenant"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: parentID, Name: "Parent", Slug: "parent"},
|
||||
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
|
||||
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
||||
|
||||
memberCounts, totalMemberCounts, err := h.countTenantMembers(context.Background(), tenants)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), memberCounts[parentID])
|
||||
require.Equal(t, int64(3), totalMemberCounts[parentID])
|
||||
require.Equal(t, int64(2), memberCounts[childID])
|
||||
require.Equal(t, int64(2), totalMemberCounts[childID])
|
||||
require.Zero(t, mockUsers.listCalls)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -358,13 +495,9 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
return strings.HasPrefix(key, "orgchart:snapshot:")
|
||||
}), mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
@@ -378,18 +511,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "MISS", resp.Header.Get("X-Orgfront-Cache"))
|
||||
var body struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.Tenants, 2)
|
||||
require.Len(t, body.Users, 1)
|
||||
require.NotEmpty(t, body.GeneratedAt)
|
||||
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotRefreshBypassesRedisHitAndUpdatesCache(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
cache := &mockOrgChartCache{}
|
||||
now := time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)
|
||||
familyID := "family"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
users := []domain.User{
|
||||
{ID: "fresh-user", Email: "fresh@example.com", Name: "Fresh User", Role: domain.RoleUser, Status: "active", TenantID: &familyID, Tenant: &tenants[0], CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.MatchedBy(func(raw string) bool {
|
||||
return strings.Contains(raw, "fresh-user")
|
||||
}), time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 1}, nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis&refresh=true", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "REFRESH", resp.Header.Get("X-Orgfront-Cache"))
|
||||
var body struct {
|
||||
Users []userSummary `json:"users"`
|
||||
Cache orgChartSnapshotCacheInfo `json:"cache"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.Users, 1)
|
||||
require.Equal(t, "fresh-user", body.Users[0].ID)
|
||||
require.Equal(t, "database", body.Cache.Source)
|
||||
require.False(t, body.Cache.Hit)
|
||||
cache.AssertNotCalled(t, "Get", mock.Anything)
|
||||
cache.AssertExpectations(t)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
|
||||
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
|
||||
ID: "super-admin-1",
|
||||
@@ -435,10 +618,8 @@ func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
|
||||
|
||||
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
|
||||
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
|
||||
@@ -469,10 +650,8 @@ func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesA
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
|
||||
|
||||
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
|
||||
|
||||
@@ -515,11 +694,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
|
||||
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
|
||||
resp, err := app.Test(req, 1000)
|
||||
@@ -538,6 +713,66 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
now := time.Date(2026, 6, 17, 13, 0, 0, 0, time.UTC)
|
||||
parent := func(id string) *string { return &id }
|
||||
familyID := "hanmac-family-id"
|
||||
companyID := "hanmac-company-id"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
identity := service.KratosIdentity{
|
||||
ID: "identity-orgchart-user",
|
||||
State: domain.UserStatusActive,
|
||||
Traits: map[string]any{
|
||||
"email": "orgchart-mirror@example.com",
|
||||
"name": "OrgChart Mirror",
|
||||
"phone_number": "010-2222-3333",
|
||||
"tenant_id": companyID,
|
||||
"tenantSlug": "hanmac",
|
||||
"position": "팀장",
|
||||
"jobTitle": "Mirror Source",
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
identityCache := newReadyIdentityMirror(t, now, identity)
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, companyID}).Return(map[string]int64{familyID: 0, companyID: 1}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Users []userSummary `json:"users"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.Users, 1)
|
||||
require.Equal(t, "identity-orgchart-user", body.Users[0].ID)
|
||||
require.Equal(t, "orgchart-mirror@example.com", body.Users[0].Email)
|
||||
require.Equal(t, "hanmac", body.Users[0].TenantSlug)
|
||||
require.Equal(t, "Mirror Source", body.Users[0].JobTitle)
|
||||
require.Equal(t, 1, identityCache.pageCalls)
|
||||
require.Equal(t, 0, identityCache.fullCalls)
|
||||
mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -566,10 +801,6 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
|
||||
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
|
||||
Return([]domain.User{}, int64(3), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
|
||||
Return([]domain.User{}, int64(2), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -612,8 +843,6 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -663,8 +892,6 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
|
||||
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1058,6 +1285,14 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
identityFixtures := make([]service.KratosIdentity, 0, len(usersByTenantID)+len(usersBySlug)+len(usersByList))
|
||||
for _, user := range usersByTenantID {
|
||||
identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
|
||||
}
|
||||
for _, user := range usersByList {
|
||||
identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
|
||||
}
|
||||
h.IdentityCache = newReadyIdentityMirror(t, now, identityFixtures...)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
|
||||
@@ -1088,7 +1323,15 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
deptPlatform := tenantsPayload[2].(map[string]any)
|
||||
platformMembers := deptPlatform["members"].([]any)
|
||||
require.Len(t, platformMembers, 3)
|
||||
firstUser := platformMembers[0].(map[string]any)
|
||||
var firstUser map[string]any
|
||||
for _, item := range platformMembers {
|
||||
member := item.(map[string]any)
|
||||
if member["email"] == "lead@example.com" {
|
||||
firstUser = member
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, firstUser)
|
||||
require.NotContains(t, firstUser, "id")
|
||||
require.NotContains(t, firstUser, "phone")
|
||||
require.NotContains(t, firstUser, "tenantIds")
|
||||
@@ -1132,6 +1375,83 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgContextJSONUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
|
||||
familyID := "group-hanmac-family"
|
||||
companyID := "company-hanmac"
|
||||
deptID := "dept-platform"
|
||||
parent := func(id string) *string { return &id }
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: deptID, Type: domain.TenantTypeUserGroup, ParentID: parent(companyID), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
identity := service.KratosIdentity{
|
||||
ID: "identity-platform-lead",
|
||||
State: domain.UserStatusActive,
|
||||
Traits: map[string]any{
|
||||
"email": "mirror-lead@example.com",
|
||||
"name": "Mirror Lead",
|
||||
"phone_number": "010-0000-0000",
|
||||
"tenant_id": companyID,
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": deptID,
|
||||
"tenantSlug": "platform",
|
||||
"isPrimary": true,
|
||||
"isOwner": true,
|
||||
"position": "실장",
|
||||
"jobTitle": "SSOT Lead",
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
rawIdentity, err := json.Marshal(identity)
|
||||
require.NoError(t, err)
|
||||
rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
|
||||
RedisReady: true,
|
||||
Status: "ready",
|
||||
ObservedCount: 1,
|
||||
MirrorVersion: identityMirrorVersion,
|
||||
LastRefreshedAt: &now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
identityCache := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(identity.ID): string(rawIdentity),
|
||||
"identity:mirror:state": string(rawStatus),
|
||||
}}}
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("apiKeyName", "orgfront-ssot-client")
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/org-context", h.GetOrgContext)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
require.Contains(t, toJSONString(t, got), "mirror-lead@example.com")
|
||||
require.Contains(t, toJSONString(t, got), "SSOT Lead")
|
||||
require.Equal(t, 1, identityCache.pageCalls)
|
||||
require.Equal(t, 0, identityCache.fullCalls)
|
||||
mockUsers.AssertNotCalled(t, "FindByTenantIDs", mock.Anything, mock.Anything)
|
||||
mockUsers.AssertNotCalled(t, "FindByCompanyCodes", mock.Anything, mock.Anything)
|
||||
mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -1152,6 +1472,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
|
||||
users := []domain.User{
|
||||
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
|
||||
@@ -1182,6 +1503,63 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
|
||||
require.NotContains(t, tree, "directUserIds")
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgContextJSONDoesNotFallbackToUserGradeForTenantMember(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("apiKeyName", "orgfront-ssot-client")
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/org-context", h.GetOrgContext)
|
||||
|
||||
now := time.Date(2026, 6, 17, 9, 0, 0, 0, time.UTC)
|
||||
tenantID := "company-hanmac"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: tenantID, Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
users := []domain.User{
|
||||
{
|
||||
ID: "user-grade-only",
|
||||
Email: "grade-only@example.com",
|
||||
Name: "직급 단독",
|
||||
Status: domain.UserStatusActive,
|
||||
TenantID: &tenantID,
|
||||
Grade: "책임",
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"tenantSlug": "hanmac",
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{tenantID}).Return(users, nil)
|
||||
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
tenantsPayload := got["tenants"].([]any)
|
||||
members := tenantsPayload[0].(map[string]any)["members"].([]any)
|
||||
require.Len(t, members, 1)
|
||||
member := members[0].(map[string]any)
|
||||
require.NotContains(t, member, "grade")
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -1206,7 +1584,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
|
||||
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUsers=false", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -1258,8 +1636,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
|
||||
mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
|
||||
Return([]domain.User{}, int64(152), "", nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1271,6 +1647,7 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
|
||||
|
||||
assert.Len(t, res.Items, 1)
|
||||
assert.Equal(t, int64(152), res.Items[0].MemberCount)
|
||||
assert.Equal(t, int64(152), res.Items[0].TotalMemberCount)
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -1622,6 +1999,46 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ImportTenantsCSVDoesNotAssignSuperAdminAsCompanyMember(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
h := &TenantHandler{Service: mockSvc}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/tenants/import", h.ImportTenantsCSV)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "tenants.csv")
|
||||
assert.NoError(t, err)
|
||||
_, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Company,COMPANY,,imported-company,,\n"))
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, writer.Close())
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
"Imported Company",
|
||||
"imported-company",
|
||||
domain.TenantTypeCompany,
|
||||
"",
|
||||
[]string{},
|
||||
(*string)(nil),
|
||||
"",
|
||||
).Return(&domain.Tenant{ID: "imported-company-id", Name: "Imported Company", Slug: "imported-company"}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants/import", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
|
||||
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
|
||||
}
|
||||
@@ -1801,9 +2218,6 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
|
||||
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
|
||||
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
|
||||
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
|
||||
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
|
||||
|
||||
@@ -1811,6 +2225,7 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUsers,
|
||||
OrgChartCache: cache,
|
||||
IdentityCache: newReadyIdentityMirror(t, now),
|
||||
}
|
||||
|
||||
app.Post("/tenants/:id/approve", h.ApproveTenant)
|
||||
|
||||
@@ -95,11 +95,41 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
|
||||
if key == "hanmacFamily" || key == "userType" {
|
||||
continue
|
||||
}
|
||||
if key == "additionalAppointments" {
|
||||
sanitized[key] = normalizeUserAppointmentGrades(value)
|
||||
continue
|
||||
}
|
||||
sanitized[key] = value
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func normalizeUserAppointmentGrades(raw any) []any {
|
||||
appointments := userAppointmentSliceFromRaw(raw)
|
||||
for i, item := range appointments {
|
||||
appointment, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if grade, ok := appointment["grade"].(string); ok {
|
||||
appointment["grade"] = normalizeInternalGradeName(grade)
|
||||
}
|
||||
appointments[i] = appointment
|
||||
}
|
||||
return appointments
|
||||
}
|
||||
|
||||
func normalizeInternalGradeName(grade string) string {
|
||||
switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
|
||||
case "상무":
|
||||
return "상무이사"
|
||||
case "전무":
|
||||
return "전무이사"
|
||||
default:
|
||||
return strings.TrimSpace(grade)
|
||||
}
|
||||
}
|
||||
|
||||
func userAppointmentSliceFromRaw(raw any) []any {
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
@@ -142,6 +172,144 @@ func userAppointmentTenantKey(raw any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func userAppointmentMatchesTenant(appointment map[string]any, tenantID string, tenantSlug string) bool {
|
||||
targetID := strings.ToLower(strings.TrimSpace(tenantID))
|
||||
targetSlug := strings.ToLower(strings.TrimSpace(tenantSlug))
|
||||
appointmentID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
|
||||
appointmentSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
|
||||
if appointmentSlug == "" {
|
||||
appointmentSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
|
||||
}
|
||||
return (targetID != "" && appointmentID == targetID) ||
|
||||
(targetSlug != "" && appointmentSlug == targetSlug)
|
||||
}
|
||||
|
||||
func tenantBoundGradeFromTraits(traits map[string]any) string {
|
||||
tenantID := extractTraitString(traits, "tenant_id")
|
||||
tenantSlug := extractTraitString(traits, "tenantSlug")
|
||||
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if metadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
for _, raw := range appointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok || !userAppointmentMatchesTenant(appointment, tenantID, tenantSlug) {
|
||||
continue
|
||||
}
|
||||
if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
|
||||
return grade
|
||||
}
|
||||
}
|
||||
for _, raw := range appointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
|
||||
continue
|
||||
}
|
||||
if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
|
||||
return grade
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tenantBoundGradeFromUser(user domain.User) string {
|
||||
if user.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
traits := map[string]any{
|
||||
"tenant_id": userTenantIDValue(user),
|
||||
}
|
||||
for key, value := range user.Metadata {
|
||||
traits[key] = value
|
||||
}
|
||||
return tenantBoundGradeFromTraits(traits)
|
||||
}
|
||||
|
||||
func userTenantIDValue(user domain.User) string {
|
||||
if user.TenantID == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*user.TenantID)
|
||||
}
|
||||
|
||||
func applyTenantBoundGrade(metadata map[string]any, tenant *domain.Tenant, grade string) map[string]any {
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
if tenant == nil || strings.TrimSpace(tenant.ID) == "" {
|
||||
return metadata
|
||||
}
|
||||
normalizedGrade := normalizeInternalGradeName(grade)
|
||||
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
|
||||
matched := false
|
||||
for i, raw := range appointments {
|
||||
appointment, ok := raw.(map[string]any)
|
||||
if !ok || !userAppointmentMatchesTenant(appointment, tenant.ID, tenant.Slug) {
|
||||
continue
|
||||
}
|
||||
appointment["tenantId"] = tenant.ID
|
||||
appointment["tenantSlug"] = tenant.Slug
|
||||
appointment["tenantName"] = tenant.Name
|
||||
if normalizedGrade == "" {
|
||||
delete(appointment, "grade")
|
||||
} else {
|
||||
appointment["grade"] = normalizedGrade
|
||||
}
|
||||
appointments[i] = appointment
|
||||
matched = true
|
||||
}
|
||||
if !matched && normalizedGrade != "" {
|
||||
appointments = append(appointments, map[string]any{
|
||||
"tenantId": tenant.ID,
|
||||
"tenantSlug": tenant.Slug,
|
||||
"tenantName": tenant.Name,
|
||||
"grade": normalizedGrade,
|
||||
})
|
||||
}
|
||||
if len(appointments) > 0 {
|
||||
metadata["additionalAppointments"] = appointments
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func applyTenantBoundGradeToTraits(ctx context.Context, tenantService service.TenantService, traits map[string]any, grade string) {
|
||||
delete(traits, "grade")
|
||||
tenantID := extractTraitString(traits, "tenant_id")
|
||||
tenantSlug := extractTraitString(traits, "tenantSlug")
|
||||
var tenant *domain.Tenant
|
||||
if tenantService != nil {
|
||||
if tenantID != "" {
|
||||
if found, err := tenantService.GetTenant(ctx, tenantID); err == nil {
|
||||
tenant = found
|
||||
}
|
||||
}
|
||||
if tenant == nil && tenantSlug != "" {
|
||||
if found, err := tenantService.GetTenantBySlug(ctx, tenantSlug); err == nil {
|
||||
tenant = found
|
||||
}
|
||||
}
|
||||
}
|
||||
if tenant == nil {
|
||||
tenant = &domain.Tenant{ID: tenantID, Slug: tenantSlug}
|
||||
}
|
||||
if strings.TrimSpace(tenant.ID) == "" && strings.TrimSpace(tenant.Slug) == "" {
|
||||
return
|
||||
}
|
||||
metadata := map[string]any{}
|
||||
if appointments, ok := traits["additionalAppointments"]; ok {
|
||||
metadata["additionalAppointments"] = appointments
|
||||
}
|
||||
metadata = applyTenantBoundGrade(metadata, tenant, grade)
|
||||
if appointments, ok := metadata["additionalAppointments"]; ok {
|
||||
traits["additionalAppointments"] = appointments
|
||||
}
|
||||
}
|
||||
|
||||
func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
|
||||
if tenant == nil {
|
||||
return metadata
|
||||
@@ -507,14 +675,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
|
||||
}
|
||||
|
||||
func gradeFromTraits(traits map[string]any) string {
|
||||
value := strings.TrimSpace(extractTraitString(traits, "grade"))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if _, ok := domain.NormalizeRoleAlias(value); ok {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
return tenantBoundGradeFromTraits(traits)
|
||||
}
|
||||
|
||||
func rejectLegacyCompanyCode(value string) error {
|
||||
@@ -631,6 +792,14 @@ type identityMirrorLister interface {
|
||||
ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error)
|
||||
}
|
||||
|
||||
type identityMirrorPageLister interface {
|
||||
ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error)
|
||||
}
|
||||
|
||||
type identityMirrorStore interface {
|
||||
StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error
|
||||
}
|
||||
|
||||
type identityMirrorStatusReader interface {
|
||||
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
|
||||
}
|
||||
@@ -856,68 +1025,29 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context())
|
||||
allowedTenantKeys := map[string]bool(nil)
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
allowedTenantKeys = manageableSlugs
|
||||
}
|
||||
page, err := h.listIdentityMirrorPageOrKratos(c.Context(), service.IdentityMirrorPageQuery{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Cursor: cursorRaw,
|
||||
Search: search,
|
||||
TenantSlug: tenantSlug,
|
||||
TenantID: targetTenantID,
|
||||
AllowedTenantKeys: allowedTenantKeys,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("Identity mirror unavailable for user list", "error", err)
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
|
||||
}
|
||||
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
searchLower := strings.ToLower(search)
|
||||
|
||||
for _, identity := range identities {
|
||||
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
|
||||
|
||||
// Tenant Admin & Member filtering
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" {
|
||||
targetKeys := map[string]bool{
|
||||
targetTenantID: true,
|
||||
strings.ToLower(tenantSlug): true,
|
||||
}
|
||||
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !identityMatchesSearch(identity, searchLower) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
|
||||
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
|
||||
total := int64(len(filtered))
|
||||
nextCursor := ""
|
||||
var pageIdentities []service.KratosIdentity
|
||||
if cursorRaw != "" {
|
||||
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
offset = 0
|
||||
} else {
|
||||
if offset > len(filtered) {
|
||||
offset = len(filtered)
|
||||
}
|
||||
end := min(offset+limit, len(filtered))
|
||||
pageIdentities = filtered[offset:end]
|
||||
if total > int64(end) && len(pageIdentities) > 0 {
|
||||
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
||||
nextCursor = pagination.Encode(lastTimestamp, lastID)
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, len(pageIdentities))
|
||||
for _, identity := range pageIdentities {
|
||||
items := make([]userSummary, 0, len(page.Items))
|
||||
for _, identity := range page.Items {
|
||||
summary := h.mapIdentitySummary(c.Context(), identity)
|
||||
items = append(items, summary)
|
||||
}
|
||||
@@ -926,9 +1056,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Total: page.Total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
NextCursor: page.NextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1024,6 +1154,84 @@ func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]s
|
||||
return h.rebuildIdentityMirror(ctx)
|
||||
}
|
||||
|
||||
func (h *UserHandler) listIdentityMirrorPageOrKratos(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
|
||||
if h != nil && h.IdentityCache != nil {
|
||||
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
|
||||
return lister.ListIdentityMirrorPage(ctx, query)
|
||||
}
|
||||
}
|
||||
|
||||
identities, err := h.rebuildIdentityMirror(ctx)
|
||||
if err != nil {
|
||||
return service.IdentityMirrorPageResult{}, err
|
||||
}
|
||||
if h != nil && h.IdentityCache != nil {
|
||||
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
|
||||
return lister.ListIdentityMirrorPage(ctx, query)
|
||||
}
|
||||
}
|
||||
return pageIdentityMirrorSlice(identities, query)
|
||||
}
|
||||
|
||||
func pageIdentityMirrorSlice(identities []service.KratosIdentity, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 50
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
searchLower := strings.ToLower(strings.TrimSpace(query.Search))
|
||||
targetKeys := make(map[string]bool)
|
||||
for _, value := range []string{query.TenantID, query.TenantSlug} {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key != "" {
|
||||
targetKeys[key] = true
|
||||
}
|
||||
}
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
|
||||
if len(query.AllowedTenantKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, query.AllowedTenantKeys) {
|
||||
continue
|
||||
}
|
||||
if len(targetKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, targetKeys) {
|
||||
continue
|
||||
}
|
||||
if !identityMatchesSearch(identity, searchLower) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
|
||||
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
|
||||
total := int64(len(filtered))
|
||||
nextCursor := ""
|
||||
var pageIdentities []service.KratosIdentity
|
||||
if strings.TrimSpace(query.Cursor) != "" {
|
||||
var err error
|
||||
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, query.Limit, query.Cursor, kratosIdentityCursorKey)
|
||||
if err != nil {
|
||||
return service.IdentityMirrorPageResult{}, err
|
||||
}
|
||||
} else {
|
||||
if query.Offset > len(filtered) {
|
||||
query.Offset = len(filtered)
|
||||
}
|
||||
end := min(query.Offset+query.Limit, len(filtered))
|
||||
pageIdentities = filtered[query.Offset:end]
|
||||
if total > int64(end) && len(pageIdentities) > 0 {
|
||||
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
|
||||
nextCursor = pagination.Encode(lastTimestamp, lastID)
|
||||
}
|
||||
}
|
||||
return service.IdentityMirrorPageResult{
|
||||
Items: pageIdentities,
|
||||
Total: total,
|
||||
Cursor: query.Cursor,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) {
|
||||
identities, err := h.rebuildIdentityMirror(ctx)
|
||||
if err != nil {
|
||||
@@ -1066,6 +1274,24 @@ func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int
|
||||
status.ObservedCount == int64(identityCount)
|
||||
}
|
||||
|
||||
func (h *UserHandler) identityMirrorStatusReady(ctx context.Context) bool {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return false
|
||||
}
|
||||
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
status, err := reader.GetIdentityCacheStatus(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return status.RedisReady &&
|
||||
status.Status == "ready" &&
|
||||
status.MirrorVersion == identityMirrorVersion &&
|
||||
status.ObservedCount > 0
|
||||
}
|
||||
|
||||
func (h *UserHandler) flushIdentityMirror(ctx context.Context) {
|
||||
if h == nil || h.IdentityCache == nil {
|
||||
return
|
||||
@@ -1108,6 +1334,10 @@ func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) {
|
||||
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
|
||||
return
|
||||
}
|
||||
if store, ok := h.IdentityCache.(identityMirrorStore); ok {
|
||||
_ = store.StoreIdentityMirror(context.Background(), identity)
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -1235,7 +1465,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
attributes := map[string]any{
|
||||
"department": req.Department,
|
||||
"grade": strings.TrimSpace(req.Grade),
|
||||
"position": req.Position,
|
||||
"jobTitle": req.JobTitle,
|
||||
"affiliationType": "internal",
|
||||
@@ -1289,6 +1518,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
tenantID = tenant.ID
|
||||
req.CompanyCode = tenant.Slug
|
||||
resolvedTenant = tenant
|
||||
}
|
||||
|
||||
attributes["role"] = role
|
||||
@@ -1308,6 +1538,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Grade) != "" {
|
||||
req.Metadata = applyTenantBoundGrade(req.Metadata, resolvedTenant, req.Grade)
|
||||
}
|
||||
|
||||
// Merge custom metadata into attributes
|
||||
for k, v := range req.Metadata {
|
||||
// Don't overwrite core fields
|
||||
@@ -1789,6 +2023,13 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
item.Metadata["additionalAppointments"] = resolvedAppointments
|
||||
}
|
||||
if strings.TrimSpace(item.Grade) != "" {
|
||||
item.Metadata = applyTenantBoundGrade(item.Metadata, &domain.Tenant{
|
||||
ID: tItem.ID,
|
||||
Slug: tItem.Slug,
|
||||
Name: tItem.Name,
|
||||
}, item.Grade)
|
||||
}
|
||||
normalizeBulkUserAliasMetadata(item.Metadata)
|
||||
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
||||
|
||||
@@ -1800,7 +2041,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
attributes := map[string]any{
|
||||
"department": dept,
|
||||
"grade": strings.TrimSpace(item.Grade),
|
||||
"position": strings.TrimSpace(item.Position),
|
||||
"jobTitle": strings.TrimSpace(item.JobTitle),
|
||||
"affiliationType": "internal",
|
||||
@@ -2350,7 +2590,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
traits["department"] = *req.Department
|
||||
}
|
||||
if req.Grade != nil {
|
||||
traits["grade"] = *req.Grade
|
||||
applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
|
||||
}
|
||||
if req.Position != nil {
|
||||
traits["position"] = *req.Position
|
||||
@@ -2783,7 +3023,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["department"] = strings.TrimSpace(*req.Department)
|
||||
}
|
||||
if req.Grade != nil {
|
||||
traits["grade"] = strings.TrimSpace(*req.Grade)
|
||||
applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
|
||||
}
|
||||
if req.Position != nil {
|
||||
traits["position"] = strings.TrimSpace(*req.Position)
|
||||
@@ -3344,7 +3584,7 @@ func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User)
|
||||
TenantSlug: tenantSlug,
|
||||
CompanyCode: tenantSlug,
|
||||
Department: user.Department,
|
||||
Grade: user.Grade,
|
||||
Grade: tenantBoundGradeFromUser(user),
|
||||
Position: user.Position,
|
||||
JobTitle: user.JobTitle,
|
||||
Metadata: user.Metadata,
|
||||
|
||||
@@ -993,6 +993,22 @@ func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T)
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyTenantBoundGradeNormalizesDirectorLevelNames(t *testing.T) {
|
||||
tenant := &domain.Tenant{ID: "tenant-1", Slug: "tenant", Name: "Tenant"}
|
||||
|
||||
metadata := applyTenantBoundGrade(nil, tenant, "상무")
|
||||
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
|
||||
require.Len(t, appointments, 1)
|
||||
appointment := appointments[0].(map[string]any)
|
||||
require.Equal(t, "상무이사", appointment["grade"])
|
||||
|
||||
metadata = applyTenantBoundGrade(metadata, tenant, "전무")
|
||||
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
|
||||
require.Len(t, appointments, 1)
|
||||
appointment = appointments[0].(map[string]any)
|
||||
require.Equal(t, "전무이사", appointment["grade"])
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
@@ -1098,9 +1114,17 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
||||
|
||||
type identityMirrorRedisStub struct {
|
||||
mockRedisRepo
|
||||
pageCalls int
|
||||
fullCalls int
|
||||
failFull bool
|
||||
lastQuery service.IdentityMirrorPageQuery
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
s.fullCalls++
|
||||
if s.failFull {
|
||||
return nil, errors.New("full identity mirror materialization is forbidden")
|
||||
}
|
||||
identities := make([]service.KratosIdentity, 0, len(s.data))
|
||||
for key, raw := range s.data {
|
||||
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
|
||||
@@ -1118,6 +1142,35 @@ func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]se
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
|
||||
s.pageCalls++
|
||||
s.lastQuery = query
|
||||
identities := make([]service.KratosIdentity, 0, len(s.data))
|
||||
for key, raw := range s.data {
|
||||
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
|
||||
continue
|
||||
}
|
||||
var identity service.KratosIdentity
|
||||
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(identity.ID) == "" {
|
||||
continue
|
||||
}
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
return pageIdentityMirrorSlice(identities, query)
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error {
|
||||
raw, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.data[identityMirrorKey(identity.ID)] = string(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
raw := s.data["identity:mirror:state"]
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
@@ -1174,7 +1227,7 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
IdentityCache: &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
|
||||
"identity:mirror:state": string(rawState),
|
||||
}}},
|
||||
@@ -1200,14 +1253,71 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "mirror-user-1", res.Items[0].ID)
|
||||
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
|
||||
cache := h.IdentityCache.(*identityMirrorRedisStub)
|
||||
require.Equal(t, 1, cache.pageCalls)
|
||||
require.Equal(t, 0, cache.fullCalls)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
|
||||
app := fiber.New()
|
||||
createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
|
||||
identities := []service.KratosIdentity{
|
||||
{ID: "user-new", State: "active", CreatedAt: createdAt.Add(2 * time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "new@example.com", "name": "New User"}},
|
||||
{ID: "user-needle", State: "active", CreatedAt: createdAt.Add(time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "needle@example.com", "name": "Needle User"}},
|
||||
{ID: "user-old", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "old@example.com", "name": "Old User"}},
|
||||
}
|
||||
data := map[string]string{}
|
||||
for _, identity := range identities {
|
||||
raw, err := json.Marshal(identity)
|
||||
require.NoError(t, err)
|
||||
data[identityMirrorKey(identity.ID)] = string(raw)
|
||||
}
|
||||
state := domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
MirrorVersion: identityMirrorVersion,
|
||||
ObservedCount: int64(len(identities)),
|
||||
}
|
||||
rawState, err := json.Marshal(state)
|
||||
require.NoError(t, err)
|
||||
data["identity:mirror:state"] = string(rawState)
|
||||
|
||||
cache := &identityMirrorRedisStub{
|
||||
mockRedisRepo: mockRedisRepo{data: data},
|
||||
failFull: true,
|
||||
}
|
||||
h := &UserHandler{
|
||||
KratosAdmin: new(MockKratosAdmin),
|
||||
IdentityCache: cache,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=1&search=needle", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "user-needle", res.Items[0].ID)
|
||||
require.Equal(t, 1, cache.pageCalls)
|
||||
require.Equal(t, 0, cache.fullCalls)
|
||||
require.Equal(t, 1, cache.lastQuery.Limit)
|
||||
require.Equal(t, "needle", cache.lastQuery.Search)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{}}}
|
||||
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
@@ -1330,7 +1440,7 @@ func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *
|
||||
|
||||
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
|
||||
}}}
|
||||
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
|
||||
@@ -1382,7 +1492,7 @@ func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing
|
||||
}
|
||||
rawLegacyState, err := json.Marshal(legacyState)
|
||||
require.NoError(t, err)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
|
||||
"identity:mirror:state": string(rawLegacyState),
|
||||
}}}
|
||||
@@ -1436,7 +1546,7 @@ func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
|
||||
}
|
||||
rawPartialIdentity, err := json.Marshal(partialIdentity)
|
||||
require.NoError(t, err)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
|
||||
}}}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
@@ -3946,18 +4056,24 @@ func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t
|
||||
mockTenant.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||
func TestUserHandler_MapToLocalUserPreservesTenantBoundGradeForCompatibility(t *testing.T) {
|
||||
handler := &UserHandler{}
|
||||
identity := service.KratosIdentity{
|
||||
ID: "user-grade-id",
|
||||
State: "active",
|
||||
Traits: map[string]any{
|
||||
"email": "grade@example.com",
|
||||
"name": "Grade User",
|
||||
"role": domain.RoleUser,
|
||||
"grade": "수석",
|
||||
"position": "팀장",
|
||||
"companyCode": "hanmac",
|
||||
"email": "grade@example.com",
|
||||
"name": "Grade User",
|
||||
"role": domain.RoleUser,
|
||||
"grade": "수석",
|
||||
"position": "팀장",
|
||||
"tenant_id": "tenant-1",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": "tenant-1",
|
||||
"grade": "수석",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3967,6 +4083,7 @@ func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||
assert.Equal(t, "수석", localUser.Grade)
|
||||
assert.Equal(t, "팀장", localUser.Position)
|
||||
assert.NotContains(t, localUser.Metadata, "grade")
|
||||
assert.Contains(t, localUser.Metadata, "additionalAppointments")
|
||||
}
|
||||
|
||||
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
|
||||
@@ -69,6 +69,26 @@ func (r *userRepository) withTenantMembershipFilter(db *gorm.DB, tenantIDs []str
|
||||
clauses = append(clauses, "metadata @> ?::jsonb")
|
||||
args = append(args, string(payload))
|
||||
}
|
||||
clauses = append(clauses, `EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS membership_tenants
|
||||
WHERE membership_tenants.id IN ?
|
||||
AND users.metadata @> jsonb_build_object(
|
||||
'additionalAppointments',
|
||||
jsonb_build_array(jsonb_build_object('tenantSlug', membership_tenants.slug))
|
||||
)
|
||||
)`)
|
||||
args = append(args, tenantIDs)
|
||||
clauses = append(clauses, `EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS membership_tenants
|
||||
WHERE membership_tenants.id IN ?
|
||||
AND users.metadata @> jsonb_build_object(
|
||||
'additionalAppointments',
|
||||
jsonb_build_array(jsonb_build_object('tenant_slug', membership_tenants.slug))
|
||||
)
|
||||
)`)
|
||||
args = append(args, tenantIDs)
|
||||
return db.Where("("+strings.Join(clauses, " OR ")+")", args...)
|
||||
}
|
||||
|
||||
@@ -170,11 +190,63 @@ func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []strin
|
||||
}
|
||||
|
||||
for _, tenantID := range tenantIDs {
|
||||
var count int64
|
||||
if err := r.withTenantMembershipFilter(r.db.WithContext(ctx).Model(&domain.User{}), []string{tenantID}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID != "" {
|
||||
counts[tenantID] = 0
|
||||
}
|
||||
counts[tenantID] = count
|
||||
}
|
||||
|
||||
type result struct {
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var results []result
|
||||
if err := r.db.WithContext(ctx).Raw(`
|
||||
WITH requested_tenants AS (
|
||||
SELECT id, slug
|
||||
FROM tenants
|
||||
WHERE id IN ?
|
||||
),
|
||||
memberships AS (
|
||||
SELECT users.id AS user_id, users.tenant_id::text AS tenant_id
|
||||
FROM users
|
||||
WHERE users.deleted_at IS NULL
|
||||
AND users.tenant_id IN ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT users.id AS user_id, appointment ->> 'tenantId' AS tenant_id
|
||||
FROM users
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
|
||||
WHERE users.deleted_at IS NULL
|
||||
AND appointment ->> 'tenantId' IN ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT users.id AS user_id, requested_tenants.id::text AS tenant_id
|
||||
FROM users
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
|
||||
JOIN requested_tenants ON LOWER(requested_tenants.slug) = LOWER(appointment ->> 'tenantSlug')
|
||||
WHERE users.deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT users.id AS user_id, requested_tenants.id::text AS tenant_id
|
||||
FROM users
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
|
||||
JOIN requested_tenants ON LOWER(requested_tenants.slug) = LOWER(appointment ->> 'tenant_slug')
|
||||
WHERE users.deleted_at IS NULL
|
||||
)
|
||||
SELECT tenant_id, COUNT(DISTINCT user_id) AS count
|
||||
FROM memberships
|
||||
WHERE tenant_id IN ?
|
||||
GROUP BY tenant_id
|
||||
`, tenantIDs, tenantIDs, tenantIDs, tenantIDs).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
counts[result.TenantID] = result.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
@@ -54,6 +54,36 @@ func TestUserRepository(t *testing.T) {
|
||||
assert.Equal(t, "010-1234-5678", found.Phone)
|
||||
})
|
||||
|
||||
t.Run("Create and Update preserve top-level user grade for compatibility", func(t *testing.T) {
|
||||
testDB.Exec("DELETE FROM user_login_ids")
|
||||
testDB.Exec("DELETE FROM users WHERE email IN ?", []string{"grade-create@example.com", "grade-update@example.com"})
|
||||
|
||||
created := &domain.User{
|
||||
Email: "grade-create@example.com",
|
||||
Name: "Grade Create",
|
||||
Role: domain.RoleUser,
|
||||
Grade: "책임",
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, created))
|
||||
|
||||
found, err := repo.FindByEmail(ctx, created.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "책임", found.Grade)
|
||||
|
||||
updated := &domain.User{
|
||||
ID: uuid.NewString(),
|
||||
Email: "grade-update@example.com",
|
||||
Name: "Grade Update",
|
||||
Role: domain.RoleUser,
|
||||
Grade: "수석",
|
||||
}
|
||||
require.NoError(t, repo.Update(ctx, updated))
|
||||
|
||||
found, err = repo.FindByEmail(ctx, updated.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "수석", found.Grade)
|
||||
})
|
||||
|
||||
t.Run("Update preserves archived email reservation", func(t *testing.T) {
|
||||
testDB.Exec("DELETE FROM user_login_ids")
|
||||
testDB.Exec("DELETE FROM users")
|
||||
@@ -273,6 +303,45 @@ func TestUserRepository_ListIncludesAdditionalTenantAppointments(t *testing.T) {
|
||||
assert.Equal(t, int64(2), counts[additionalTenant.ID])
|
||||
}
|
||||
|
||||
func TestUserRepository_ListIncludesAdditionalTenantAppointmentsBySlug(t *testing.T) {
|
||||
repo := NewUserRepository(testDB)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
|
||||
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
|
||||
|
||||
primaryTenant := createUserRepositoryTestTenant(t, "repo-private-primary-tenant")
|
||||
visibleTenant := createUserRepositoryTestTenant(t, "repo-visible-leader-tenant")
|
||||
primaryTenantID := primaryTenant.ID
|
||||
user := domain.User{
|
||||
ID: uuid.NewString(),
|
||||
Email: "slug-appointment-leader@example.com",
|
||||
Name: "Slug Appointment Leader",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &primaryTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantSlug": visibleTenant.Slug,
|
||||
"tenantName": visibleTenant.Name,
|
||||
"isOwner": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, &user))
|
||||
|
||||
listed, total, _, err := repo.List(ctx, 0, 20, "", []string{visibleTenant.ID}, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), total)
|
||||
require.Len(t, listed, 1)
|
||||
assert.Equal(t, "slug-appointment-leader@example.com", listed[0].Email)
|
||||
|
||||
counts, err := repo.CountByTenantIDs(ctx, []string{visibleTenant.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), counts[visibleTenant.ID])
|
||||
}
|
||||
|
||||
func createUserRepositoryTestTenant(t *testing.T, slug string) domain.Tenant {
|
||||
t.Helper()
|
||||
require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
|
||||
|
||||
@@ -2,9 +2,12 @@ package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/pagination"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,10 +24,28 @@ type identityMirrorStateStore struct {
|
||||
Status string `json:"status"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
MirrorVersion string `json:"mirrorVersion,omitempty"`
|
||||
ObservedCount int64 `json:"observedCount,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type IdentityMirrorPageQuery struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Cursor string
|
||||
Search string
|
||||
TenantID string
|
||||
TenantSlug string
|
||||
AllowedTenantKeys map[string]bool
|
||||
}
|
||||
|
||||
type IdentityMirrorPageResult struct {
|
||||
Items []KratosIdentity
|
||||
Total int64
|
||||
Cursor string
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
// NewRedisService creates and returns a new RedisService
|
||||
func NewRedisService() (*RedisService, error) {
|
||||
redisAddr := os.Getenv("REDIS_ADDR")
|
||||
@@ -199,6 +220,7 @@ func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.Ident
|
||||
return domain.IdentityCacheStatus{
|
||||
Status: status,
|
||||
RedisReady: true,
|
||||
MirrorVersion: stored.MirrorVersion,
|
||||
ObservedCount: stored.ObservedCount,
|
||||
KeyCount: keyCount,
|
||||
LastRefreshedAt: stored.LastRefreshedAt,
|
||||
@@ -271,6 +293,269 @@ func (s *RedisService) ListIdentityMirrors(ctx context.Context) ([]KratosIdentit
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *RedisService) StoreIdentityMirror(ctx context.Context, identity KratosIdentity) error {
|
||||
if s == nil || s.Client == nil {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
identityID := strings.TrimSpace(identity.ID)
|
||||
if identityID == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
raw, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Client.Set(ctx, "identity:mirror:"+identityID, string(raw), 0).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
score := float64(identityMirrorScoreTime(identity).UnixMilli())
|
||||
if err := s.Client.ZAdd(ctx, "identity:index:created_at", &redis.Z{
|
||||
Score: score,
|
||||
Member: identityID,
|
||||
}).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tenantKey := range identityMirrorTenantKeys(identity.Traits) {
|
||||
if err := s.Client.SAdd(ctx, "identity:index:tenant:"+tenantKey, identityID).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedisService) ListIdentityMirrorPage(ctx context.Context, query IdentityMirrorPageQuery) (IdentityMirrorPageResult, error) {
|
||||
if s == nil || s.Client == nil {
|
||||
return IdentityMirrorPageResult{}, os.ErrInvalid
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 50
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
cursor, err := pagination.Decode(query.Cursor)
|
||||
if err != nil {
|
||||
return IdentityMirrorPageResult{}, err
|
||||
}
|
||||
search := strings.ToLower(strings.TrimSpace(query.Search))
|
||||
targetTenantKeys := identityMirrorTargetTenantKeys(query)
|
||||
maxScore := "+inf"
|
||||
if cursor != nil {
|
||||
maxScore = strconv.FormatInt(cursor.Timestamp.UnixMilli(), 10)
|
||||
}
|
||||
|
||||
const batchSize int64 = 250
|
||||
var offset int64
|
||||
var total int64
|
||||
matched := make([]KratosIdentity, 0, query.Limit+1)
|
||||
pageStart := query.Offset
|
||||
if cursor != nil {
|
||||
pageStart = 0
|
||||
}
|
||||
|
||||
for {
|
||||
zItems, err := s.Client.ZRevRangeByScoreWithScores(ctx, "identity:index:created_at", &redis.ZRangeBy{
|
||||
Max: maxScore,
|
||||
Min: "-inf",
|
||||
Offset: offset,
|
||||
Count: batchSize,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return IdentityMirrorPageResult{}, err
|
||||
}
|
||||
if len(zItems) == 0 {
|
||||
break
|
||||
}
|
||||
keys := make([]string, 0, len(zItems))
|
||||
for _, item := range zItems {
|
||||
id, ok := item.Member.(string)
|
||||
if !ok || strings.TrimSpace(id) == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, "identity:mirror:"+id)
|
||||
}
|
||||
rawItems, err := s.Client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return IdentityMirrorPageResult{}, err
|
||||
}
|
||||
for _, raw := range rawItems {
|
||||
rawString, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(rawString) == "" {
|
||||
continue
|
||||
}
|
||||
var identity KratosIdentity
|
||||
if err := json.Unmarshal([]byte(rawString), &identity); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(identity.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if cursor != nil {
|
||||
timestamp, id := identityMirrorCursorKey(identity)
|
||||
if !pagination.ComesAfter(timestamp, id, cursor) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !identityMirrorMatchesTenantScope(identity, targetTenantKeys, query.AllowedTenantKeys) {
|
||||
continue
|
||||
}
|
||||
if !identityMirrorMatchesSearch(identity, search) {
|
||||
continue
|
||||
}
|
||||
if total >= int64(pageStart) && len(matched) < query.Limit+1 {
|
||||
matched = append(matched, identity)
|
||||
}
|
||||
total++
|
||||
}
|
||||
if len(zItems) < int(batchSize) {
|
||||
break
|
||||
}
|
||||
offset += int64(len(zItems))
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
items := matched
|
||||
if len(matched) > query.Limit {
|
||||
items = matched[:query.Limit]
|
||||
lastTimestamp, lastID := identityMirrorCursorKey(items[len(items)-1])
|
||||
nextCursor = pagination.Encode(lastTimestamp, lastID)
|
||||
}
|
||||
return IdentityMirrorPageResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Cursor: query.Cursor,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func identityMirrorScoreTime(identity KratosIdentity) time.Time {
|
||||
if identity.CreatedAt.IsZero() {
|
||||
return time.Unix(0, 0).UTC()
|
||||
}
|
||||
return identity.CreatedAt.UTC()
|
||||
}
|
||||
|
||||
func identityMirrorCursorKey(identity KratosIdentity) (time.Time, string) {
|
||||
return identityMirrorScoreTime(identity), identity.ID
|
||||
}
|
||||
|
||||
func identityMirrorTenantKeys(traits map[string]any) []string {
|
||||
keys := make([]string, 0, 4)
|
||||
seen := make(map[string]bool)
|
||||
appendKey := func(value string) {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
}
|
||||
appendKey(identityMirrorTraitString(traits, "tenant_id"))
|
||||
appendKey(identityMirrorTraitString(traits, "tenantSlug"))
|
||||
appointments := identityMirrorAppointments(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if metadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = identityMirrorAppointments(metadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
for _, appointment := range appointments {
|
||||
appendKey(identityMirrorAnyString(appointment["tenantId"]))
|
||||
appendKey(identityMirrorAnyString(appointment["tenantSlug"]))
|
||||
appendKey(identityMirrorAnyString(appointment["slug"]))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func identityMirrorTargetTenantKeys(query IdentityMirrorPageQuery) map[string]bool {
|
||||
targets := make(map[string]bool)
|
||||
for _, value := range []string{query.TenantID, query.TenantSlug} {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key != "" {
|
||||
targets[key] = true
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func identityMirrorMatchesTenantScope(identity KratosIdentity, targetTenantKeys map[string]bool, allowedTenantKeys map[string]bool) bool {
|
||||
identityKeys := identityMirrorTenantKeys(identity.Traits)
|
||||
if len(allowedTenantKeys) > 0 && !identityMirrorAnyKeyAllowed(identityKeys, allowedTenantKeys) {
|
||||
return false
|
||||
}
|
||||
if len(targetTenantKeys) > 0 && !identityMirrorAnyKeyAllowed(identityKeys, targetTenantKeys) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func identityMirrorAnyKeyAllowed(keys []string, allowed map[string]bool) bool {
|
||||
for _, key := range keys {
|
||||
if allowed[key] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func identityMirrorMatchesSearch(identity KratosIdentity, search string) bool {
|
||||
search = strings.TrimSpace(search)
|
||||
if search == "" {
|
||||
return true
|
||||
}
|
||||
values := []string{
|
||||
identity.ID,
|
||||
identityMirrorTraitString(identity.Traits, "email"),
|
||||
identityMirrorTraitString(identity.Traits, "name"),
|
||||
identityMirrorTraitString(identity.Traits, "phone_number"),
|
||||
identityMirrorTraitString(identity.Traits, "loginId"),
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.Contains(strings.ToLower(value), search) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
rawTraits, err := json.Marshal(identity.Traits)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(rawTraits)), search)
|
||||
}
|
||||
|
||||
func identityMirrorTraitString(traits map[string]any, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
return identityMirrorAnyString(traits[key])
|
||||
}
|
||||
|
||||
func identityMirrorAnyString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func identityMirrorAppointments(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
result := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if appointment, ok := item.(map[string]any); ok {
|
||||
result = append(result, appointment)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
|
||||
keys, err := s.identityCacheKeys(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -100,22 +100,14 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||
adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
|
||||
idMap := make(map[string]bool)
|
||||
allIDs := make([]string, 0, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || idMap[id] {
|
||||
continue
|
||||
}
|
||||
idMap[id] = true
|
||||
}
|
||||
for _, id := range ownerIDs {
|
||||
idMap[id] = true
|
||||
}
|
||||
for _, id := range adminIDs {
|
||||
idMap[id] = true
|
||||
}
|
||||
|
||||
allIDs := make([]string, 0, len(idMap))
|
||||
for id := range idMap {
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,11 @@ func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName stri
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
args := m.Called(ctx, ids)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
||||
@@ -343,3 +347,29 @@ func TestTenantService_ListTenants(t *testing.T) {
|
||||
assert.Equal(t, tenants, result)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_ListJoinedTenants_UsesOnlyMemberRelations(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
mockKeto := new(MockKetoSvcForTenant)
|
||||
svc := NewTenantService(mockRepo, nil, nil, nil)
|
||||
svc.SetKetoService(mockKeto)
|
||||
ctx := context.Background()
|
||||
userID := "user-uuid"
|
||||
memberTenant := domain.Tenant{ID: "tenant-member", Slug: "actual-member"}
|
||||
|
||||
mockKeto.On("ListObjects", ctx, "Tenant", "members", "User:"+userID).
|
||||
Return([]string{memberTenant.ID}, nil).
|
||||
Once()
|
||||
mockRepo.On("FindByIDs", ctx, []string{memberTenant.ID}).
|
||||
Return([]domain.Tenant{memberTenant}, nil).
|
||||
Once()
|
||||
|
||||
result, err := svc.ListJoinedTenants(ctx, userID)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []domain.Tenant{memberTenant}, result)
|
||||
mockKeto.AssertNotCalled(t, "ListObjects", ctx, "Tenant", "owners", "User:"+userID)
|
||||
mockKeto.AssertNotCalled(t, "ListObjects", ctx, "Tenant", "admins", "User:"+userID)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ type WorksmobileHTTPClient struct {
|
||||
DomainIDs []int64
|
||||
OrgUnitWriteDelay time.Duration
|
||||
tokenCache worksmobileAccessTokenCache
|
||||
levelCache map[int64][]WorksmobileUserLevel
|
||||
levelCacheMu sync.Mutex
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -326,8 +328,21 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
var err error
|
||||
payload = normalizeWorksmobileUserCreatePayload(payload)
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
|
||||
levelDomainID := worksmobilePayloadLevelDomainID(payload)
|
||||
levelID, err := c.resolveWorksmobilePayloadLevelIDForDomain(ctx, payload, levelDomainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.LevelID = ""
|
||||
if err := c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if levelID != "" {
|
||||
return c.PatchUserOrganizationLevelByName(ctx, payload.Email, levelDomainID, levelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
@@ -346,6 +361,12 @@ func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload Work
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
|
||||
levelDomainID := worksmobilePayloadLevelDomainID(payload)
|
||||
levelID, err := c.resolveWorksmobilePayloadLevelIDForDomain(ctx, payload, levelDomainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.LevelID = ""
|
||||
identifier := strings.TrimSpace(payload.Email)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
@@ -369,6 +390,9 @@ func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, paylo
|
||||
}
|
||||
return patchErr
|
||||
}
|
||||
if levelID != "" {
|
||||
return c.PatchUserOrganizationLevelByName(ctx, identifier, levelDomainID, levelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -585,6 +609,221 @@ func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUserLevel(ctx context.Context, identifier string, domainID int64, levelID string) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
levelID = strings.TrimSpace(levelID)
|
||||
if identifier == "" {
|
||||
return fmt.Errorf("worksmobile user identifier is required")
|
||||
}
|
||||
if domainID <= 0 {
|
||||
return fmt.Errorf("worksmobile domain id is required")
|
||||
}
|
||||
if levelID == "" {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"domainId": domainID,
|
||||
"level": WorksmobileUserLevelRef{
|
||||
LevelID: levelID,
|
||||
},
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUserLevelByName(ctx context.Context, identifier string, domainID int64, levelName string) error {
|
||||
payload, err := c.resolveWorksmobilePayloadLevelID(ctx, WorksmobileUserPayload{
|
||||
DomainID: domainID,
|
||||
LevelID: levelName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.PatchUserLevel(ctx, identifier, domainID, payload.LevelID)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUserOrganizationLevelByName(ctx context.Context, identifier string, domainID int64, levelName string) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return fmt.Errorf("worksmobile user identifier is required")
|
||||
}
|
||||
payload, err := c.resolveWorksmobilePayloadLevelID(ctx, WorksmobileUserPayload{
|
||||
DomainID: domainID,
|
||||
LevelID: levelName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(identifier), &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
rawOrganizations, ok := raw["organizations"].([]any)
|
||||
if !ok || len(rawOrganizations) == 0 {
|
||||
return fmt.Errorf("worksmobile user organizations are missing: %s", identifier)
|
||||
}
|
||||
organizations := make([]any, 0, len(rawOrganizations))
|
||||
updated := false
|
||||
for _, rawOrganization := range rawOrganizations {
|
||||
organization, ok := rawOrganization.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next := make(map[string]any, len(organization)+1)
|
||||
for key, value := range organization {
|
||||
next[key] = value
|
||||
}
|
||||
if !updated && worksmobileRawDomainID(next["domainId"]) == domainID {
|
||||
next["levelId"] = payload.LevelID
|
||||
updated = true
|
||||
}
|
||||
organizations = append(organizations, next)
|
||||
}
|
||||
if !updated {
|
||||
return fmt.Errorf("worksmobile user organization not found for domain_id=%d: %s", domainID, identifier)
|
||||
}
|
||||
request := map[string]any{
|
||||
"domainId": domainID,
|
||||
"email": firstStringFromMap(raw, "email", "loginId", "userName"),
|
||||
"userName": raw["userName"],
|
||||
"organizations": organizations,
|
||||
}
|
||||
if value := firstStringFromMap(raw, "userExternalKey", "externalKey", "externalId"); value != "" {
|
||||
request["userExternalKey"] = value
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), request)
|
||||
}
|
||||
|
||||
func worksmobileRawDomainID(raw any) int64 {
|
||||
switch value := raw.(type) {
|
||||
case int64:
|
||||
return value
|
||||
case int:
|
||||
return int64(value)
|
||||
case float64:
|
||||
return int64(value)
|
||||
case json.Number:
|
||||
parsed, _ := value.Int64()
|
||||
return parsed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ListUserLevels(ctx context.Context, domainID int64) ([]WorksmobileUserLevel, error) {
|
||||
if domainID <= 0 {
|
||||
return nil, fmt.Errorf("worksmobile domain id is required")
|
||||
}
|
||||
c.levelCacheMu.Lock()
|
||||
if c.levelCache != nil {
|
||||
if cached, ok := c.levelCache[domainID]; ok {
|
||||
c.levelCacheMu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
c.levelCacheMu.Unlock()
|
||||
|
||||
var response struct {
|
||||
Levels []WorksmobileUserLevel `json:"levels"`
|
||||
}
|
||||
if err := c.getDirectoryJSON(ctx, "/v1.0/users/levels?domainId="+strconv.FormatInt(domainID, 10), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.levelCacheMu.Lock()
|
||||
if c.levelCache == nil {
|
||||
c.levelCache = map[int64][]WorksmobileUserLevel{}
|
||||
}
|
||||
c.levelCache[domainID] = response.Levels
|
||||
c.levelCacheMu.Unlock()
|
||||
return response.Levels, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) resolveWorksmobilePayloadLevelID(ctx context.Context, payload WorksmobileUserPayload) (WorksmobileUserPayload, error) {
|
||||
level := strings.TrimSpace(payload.LevelID)
|
||||
if level == "" {
|
||||
return payload, nil
|
||||
}
|
||||
if isLikelyWorksmobileUUID(level) {
|
||||
payload.LevelID = level
|
||||
return payload, nil
|
||||
}
|
||||
if isWorksmobileExternalKeyLevelID(level) {
|
||||
payload.LevelID = level
|
||||
return payload, nil
|
||||
}
|
||||
levels, err := c.ListUserLevels(ctx, payload.DomainID)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
for _, candidate := range levels {
|
||||
if strings.TrimSpace(candidate.LevelID) == level || strings.TrimSpace(candidate.LevelName) == level {
|
||||
payload.LevelID = strings.TrimSpace(candidate.LevelID)
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
return WorksmobileUserPayload{}, fmt.Errorf("worksmobile level not found: domain_id=%d level=%s", payload.DomainID, level)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) resolveWorksmobilePayloadLevelIDForDomain(ctx context.Context, payload WorksmobileUserPayload, domainID int64) (string, error) {
|
||||
level := strings.TrimSpace(payload.LevelID)
|
||||
if level == "" {
|
||||
return "", nil
|
||||
}
|
||||
levelPayload := payload
|
||||
levelPayload.DomainID = domainID
|
||||
resolved, err := c.resolveWorksmobilePayloadLevelID(ctx, levelPayload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(resolved.LevelID), nil
|
||||
}
|
||||
|
||||
func worksmobilePayloadLevelDomainID(payload WorksmobileUserPayload) int64 {
|
||||
if payload.LevelDomainID > 0 {
|
||||
return payload.LevelDomainID
|
||||
}
|
||||
if domainID := worksmobilePayloadPrimaryOrganizationDomainID(payload); domainID > 0 {
|
||||
return domainID
|
||||
}
|
||||
return payload.DomainID
|
||||
}
|
||||
|
||||
func worksmobilePayloadPrimaryOrganizationDomainID(payload WorksmobileUserPayload) int64 {
|
||||
for _, organization := range payload.Organizations {
|
||||
if organization.Primary && organization.DomainID > 0 {
|
||||
return organization.DomainID
|
||||
}
|
||||
}
|
||||
for _, organization := range payload.Organizations {
|
||||
if organization.DomainID > 0 {
|
||||
return organization.DomainID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isLikelyWorksmobileUUID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, ch := range value {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
if ch != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
@@ -1074,6 +1313,15 @@ type WorksmobileUserPatchPayload struct {
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileUserLevelRef struct {
|
||||
LevelID string `json:"levelId"`
|
||||
}
|
||||
|
||||
type WorksmobileUserLevel struct {
|
||||
LevelID string `json:"levelId"`
|
||||
LevelName string `json:"levelName"`
|
||||
}
|
||||
|
||||
type WorksmobileOrgUnitPatchPayload struct {
|
||||
DomainID int64 `json:"domainId"`
|
||||
Email string `json:"email,omitempty"`
|
||||
@@ -1268,6 +1516,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
"employeeId",
|
||||
"employeeID",
|
||||
),
|
||||
DomainID: worksmobileRawDomainID(resource["domainId"]),
|
||||
LevelID: parseWorksmobileUserLevelID(resource),
|
||||
LevelName: parseWorksmobileUserLevelName(resource),
|
||||
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
|
||||
@@ -1396,6 +1645,9 @@ func parseWorksmobileUserLevelID(resource map[string]any) string {
|
||||
if level, ok := resource["level"].(map[string]any); ok {
|
||||
return firstStringFromMap(level, "levelId", "id", "value")
|
||||
}
|
||||
if value := parseWorksmobileOrganizationLevel(resource, "levelId", "id", "value"); value != "" {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1406,9 +1658,42 @@ func parseWorksmobileUserLevelName(resource map[string]any) string {
|
||||
if level, ok := resource["level"].(map[string]any); ok {
|
||||
return firstStringFromMap(level, "levelName", "displayName", "name")
|
||||
}
|
||||
if value := parseWorksmobileOrganizationLevel(resource, "levelName", "displayName", "name"); value != "" {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseWorksmobileOrganizationLevel(resource map[string]any, keys ...string) string {
|
||||
rawOrganizations, ok := resource["organizations"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
fallback := ""
|
||||
for _, raw := range rawOrganizations {
|
||||
organization, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value := firstStringFromMap(organization, keys...)
|
||||
if value == "" {
|
||||
if level, ok := organization["level"].(map[string]any); ok {
|
||||
value = firstStringFromMap(level, keys...)
|
||||
}
|
||||
}
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if boolFromMap(organization, "primary") {
|
||||
return value
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type worksmobileOrgUnitDetail struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -1481,11 +1766,23 @@ func parseWorksmobileUserOrganizationList(raw any) []WorksmobileUserOrganization
|
||||
if len(orgUnits) == 0 {
|
||||
continue
|
||||
}
|
||||
levelID := firstStringFromMap(organization, "levelId")
|
||||
levelName := firstStringFromMap(organization, "levelName")
|
||||
if level, ok := organization["level"].(map[string]any); ok {
|
||||
if levelID == "" {
|
||||
levelID = firstStringFromMap(level, "levelId", "id", "value")
|
||||
}
|
||||
if levelName == "" {
|
||||
levelName = firstStringFromMap(level, "levelName", "displayName", "name")
|
||||
}
|
||||
}
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: int64FromMap(organization, "domainId"),
|
||||
Email: firstStringFromMap(organization, "email"),
|
||||
Primary: boolFromMap(organization, "primary"),
|
||||
OrgUnits: orgUnits,
|
||||
DomainID: int64FromMap(organization, "domainId"),
|
||||
Email: firstStringFromMap(organization, "email"),
|
||||
Primary: boolFromMap(organization, "primary"),
|
||||
LevelID: levelID,
|
||||
LevelName: levelName,
|
||||
OrgUnits: orgUnits,
|
||||
})
|
||||
}
|
||||
return organizations
|
||||
|
||||
@@ -92,10 +92,263 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
|
||||
DomainID: 1001,
|
||||
Email: "phone-canonical@samaneng.com",
|
||||
CellPhone: "+82+821062836786",
|
||||
LevelID: "level-manager",
|
||||
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:tenant-1", Primary: true}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, "+82 01062836786", payload.CellPhone)
|
||||
data, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "level-manager")
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpdateUserResolvesLevelNameToLevelObject(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-deputy","levelName":"대리"}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "grade@samaneng.com",
|
||||
LevelID: "대리",
|
||||
UserName: WorksmobileUserName{LastName: "Grade User"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 4)
|
||||
require.Equal(t, http.MethodGet, transport.requests[0].Method)
|
||||
require.Equal(t, "/v1.0/users/levels", transport.requests[0].URL.Path)
|
||||
require.Equal(t, "300286336", transport.requests[0].URL.Query().Get("domainId"))
|
||||
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
|
||||
require.Equal(t, http.MethodGet, transport.requests[2].Method)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[3].Method)
|
||||
|
||||
var fullPatch map[string]any
|
||||
require.Len(t, transport.requestBodies, 2)
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &fullPatch))
|
||||
require.NotContains(t, fullPatch, "level")
|
||||
require.NotContains(t, fullPatch, "levelId")
|
||||
|
||||
var levelPatch map[string]any
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
|
||||
organizations := levelPatch["organizations"].([]any)
|
||||
organization := organizations[0].(map[string]any)
|
||||
require.Equal(t, "level-deputy", organization["levelId"])
|
||||
require.NotContains(t, levelPatch, "levelId")
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpdateUserPassesLevelExternalKeyThrough(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "grade@samaneng.com",
|
||||
LevelID: "externalKey:lead",
|
||||
UserName: WorksmobileUserName{LastName: "Grade User"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 3)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[0].Method)
|
||||
require.Equal(t, http.MethodGet, transport.requests[1].Method)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
|
||||
|
||||
var levelPatch map[string]any
|
||||
require.Len(t, transport.requestBodies, 2)
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
|
||||
organizations := levelPatch["organizations"].([]any)
|
||||
organization := organizations[0].(map[string]any)
|
||||
require.Equal(t, "externalKey:lead", organization["levelId"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpdateUserInfersLevelDomainFromPrimaryOrganization(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
{statusCode: http.StatusOK, body: `{"domainId":300285955,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300285955,"primary":false,"orgUnits":[{"orgUnitId":"works-saman","primary":true}]},{"domainId":300286337,"primary":true,"orgUnits":[{"orgUnitId":"works-gpdtdc","primary":true}]}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300285955,
|
||||
Email: "grade@samaneng.com",
|
||||
LevelID: "externalKey:prin",
|
||||
UserName: WorksmobileUserName{LastName: "Grade User"},
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300286337,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:gpdtdc", Primary: true}},
|
||||
},
|
||||
{
|
||||
DomainID: 300285955,
|
||||
Primary: false,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:saman", Primary: true}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 3)
|
||||
|
||||
var levelPatch map[string]any
|
||||
require.Len(t, transport.requestBodies, 2)
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
|
||||
require.Equal(t, float64(300286337), levelPatch["domainId"])
|
||||
organizations := levelPatch["organizations"].([]any)
|
||||
require.NotContains(t, organizations[0].(map[string]any), "levelId")
|
||||
require.Equal(t, "externalKey:prin", organizations[1].(map[string]any)["levelId"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientUpdateUserUsesLevelDomainForOrganizationLevel(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-principal","levelName":"수석"}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":false,"orgUnits":[{"orgUnitId":"works-hanmac","primary":true}]},{"domainId":300286337,"primary":true,"orgUnits":[{"orgUnitId":"works-gsim","primary":true}]}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
LevelDomainID: 300286337,
|
||||
Email: "grade@samaneng.com",
|
||||
LevelID: "수석",
|
||||
UserName: WorksmobileUserName{LastName: "Grade User"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 4)
|
||||
require.Equal(t, http.MethodGet, transport.requests[0].Method)
|
||||
require.Equal(t, "/v1.0/users/levels", transport.requests[0].URL.Path)
|
||||
require.Equal(t, "300286337", transport.requests[0].URL.Query().Get("domainId"))
|
||||
|
||||
var levelPatch map[string]any
|
||||
require.Len(t, transport.requestBodies, 2)
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
|
||||
require.Equal(t, float64(300286337), levelPatch["domainId"])
|
||||
organizations := levelPatch["organizations"].([]any)
|
||||
require.NotContains(t, organizations[0].(map[string]any), "levelId")
|
||||
require.Equal(t, "level-principal", organizations[1].(map[string]any)["levelId"])
|
||||
}
|
||||
|
||||
func TestDecodeWorksmobileUserRequestAcceptsStoredLevelName(t *testing.T) {
|
||||
var payload WorksmobileUserPayload
|
||||
|
||||
err := decodeWorksmobileRequest(domain.JSONMap{
|
||||
"request": map[string]any{
|
||||
"domainId": int64(300286336),
|
||||
"email": "grade@samaneng.com",
|
||||
"levelName": "대리",
|
||||
"userName": map[string]any{"lastName": "Grade User"},
|
||||
},
|
||||
}, &payload)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "대리", payload.LevelID)
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONPreservesLevelDomainID(t *testing.T) {
|
||||
encoded, err := json.Marshal(WorksmobileUserPayload{
|
||||
DomainID: 300285955,
|
||||
LevelDomainID: 300286337,
|
||||
Email: "tester@samaneng.com",
|
||||
LevelID: "externalKey:prin",
|
||||
UserName: WorksmobileUserName{LastName: "Tester"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var raw map[string]any
|
||||
require.NoError(t, json.Unmarshal(encoded, &raw))
|
||||
require.Equal(t, float64(300286337), raw["levelDomainId"])
|
||||
|
||||
var decoded WorksmobileUserPayload
|
||||
require.NoError(t, json.Unmarshal(encoded, &decoded))
|
||||
require.Equal(t, int64(300286337), decoded.LevelDomainID)
|
||||
require.Equal(t, "externalKey:prin", decoded.LevelID)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientCreateUserSendsLevelInSeparatePatch(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-deputy","levelName":"대리"}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
{statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
DirectoryToken: "directory-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.CreateUser(context.Background(), WorksmobileUserPayload{
|
||||
DomainID: 300286336,
|
||||
Email: "grade@samaneng.com",
|
||||
LevelID: "대리",
|
||||
UserName: WorksmobileUserName{LastName: "Grade User"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 4)
|
||||
require.Equal(t, http.MethodGet, transport.requests[0].Method)
|
||||
require.Equal(t, http.MethodPost, transport.requests[1].Method)
|
||||
require.Equal(t, http.MethodGet, transport.requests[2].Method)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[3].Method)
|
||||
|
||||
var createPayload map[string]any
|
||||
require.Len(t, transport.requestBodies, 2)
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &createPayload))
|
||||
require.NotContains(t, createPayload, "level")
|
||||
require.NotContains(t, createPayload, "levelId")
|
||||
require.NotContains(t, createPayload, "levelName")
|
||||
|
||||
var levelPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPayload))
|
||||
organizations := levelPayload["organizations"].([]any)
|
||||
organization := organizations[0].(map[string]any)
|
||||
require.Equal(t, "level-deputy", organization["levelId"])
|
||||
}
|
||||
|
||||
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
|
||||
@@ -1561,6 +1814,26 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
|
||||
require.True(t, *user.OrgUnitManagers["works-org-1"])
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryUserReadsOrganizationLevel(t *testing.T) {
|
||||
user := parseWorksmobileDirectoryUser(map[string]any{
|
||||
"userId": "works-user",
|
||||
"email": "tester@samaneng.com",
|
||||
"userName": map[string]any{
|
||||
"lastName": "홍길동",
|
||||
},
|
||||
"organizations": []any{
|
||||
map[string]any{
|
||||
"primary": true,
|
||||
"levelId": "level-1",
|
||||
"levelName": "책임",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, "level-1", user.LevelID)
|
||||
require.Equal(t, "책임", user.LevelName)
|
||||
}
|
||||
|
||||
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
|
||||
user := parseWorksmobileDirectoryUser(map[string]any{
|
||||
"userId": "works-user",
|
||||
@@ -1572,7 +1845,9 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
|
||||
},
|
||||
"organizations": []any{
|
||||
map[string]any{
|
||||
"primary": true,
|
||||
"primary": true,
|
||||
"levelId": "level-1",
|
||||
"levelName": "책임",
|
||||
"orgUnits": []any{
|
||||
map[string]any{
|
||||
"orgUnitId": "externalKey:primary-org",
|
||||
@@ -1598,7 +1873,9 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
|
||||
require.Equal(t, "EMP001", user.EmployeeNumber)
|
||||
require.Equal(t, []WorksmobileUserOrganization{
|
||||
{
|
||||
Primary: true,
|
||||
Primary: true,
|
||||
LevelID: "level-1",
|
||||
LevelName: "책임",
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{OrgUnitID: "externalKey:primary-org", Primary: true, IsManager: boolPtr(false)},
|
||||
{OrgUnitID: "externalKey:secondary-org", Primary: false, IsManager: boolPtr(true)},
|
||||
|
||||
@@ -38,6 +38,8 @@ type WorksmobileUserPayload struct {
|
||||
PrivateEmail string `json:"privateEmail,omitempty"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LevelID string `json:"-"`
|
||||
LevelDomainID int64 `json:"levelDomainId,omitempty"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
@@ -70,6 +72,8 @@ func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
|
||||
PrivateEmail string `json:"privateEmail,omitempty"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LevelName string `json:"levelName,omitempty"`
|
||||
LevelDomainID int64 `json:"levelDomainId,omitempty"`
|
||||
PasswordConfig *WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
@@ -90,22 +94,75 @@ func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
|
||||
PrivateEmail: p.PrivateEmail,
|
||||
AliasEmails: p.AliasEmails,
|
||||
Locale: p.Locale,
|
||||
LevelName: strings.TrimSpace(p.LevelID),
|
||||
LevelDomainID: p.LevelDomainID,
|
||||
PasswordConfig: passwordConfig,
|
||||
Task: p.Task,
|
||||
Organizations: p.Organizations,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *WorksmobileUserPayload) UnmarshalJSON(data []byte) error {
|
||||
type payloadJSON struct {
|
||||
DomainID int64 `json:"domainId"`
|
||||
Email string `json:"email"`
|
||||
UserExternalKey string `json:"userExternalKey,omitempty"`
|
||||
UserName WorksmobileUserName `json:"userName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
PrivateEmail string `json:"privateEmail,omitempty"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
LevelID string `json:"levelId,omitempty"`
|
||||
LevelName string `json:"levelName,omitempty"`
|
||||
LevelDomainID int64 `json:"levelDomainId,omitempty"`
|
||||
Level *WorksmobileUserLevelRef `json:"level,omitempty"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
}
|
||||
var raw payloadJSON
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
levelID := strings.TrimSpace(raw.LevelName)
|
||||
if levelID == "" {
|
||||
levelID = strings.TrimSpace(raw.LevelID)
|
||||
}
|
||||
if levelID == "" && raw.Level != nil {
|
||||
levelID = strings.TrimSpace(raw.Level.LevelID)
|
||||
}
|
||||
*p = WorksmobileUserPayload{
|
||||
DomainID: raw.DomainID,
|
||||
Email: raw.Email,
|
||||
UserExternalKey: raw.UserExternalKey,
|
||||
UserName: raw.UserName,
|
||||
CellPhone: raw.CellPhone,
|
||||
EmployeeNumber: raw.EmployeeNumber,
|
||||
PrivateEmail: raw.PrivateEmail,
|
||||
AliasEmails: raw.AliasEmails,
|
||||
Locale: raw.Locale,
|
||||
LevelID: levelID,
|
||||
LevelDomainID: raw.LevelDomainID,
|
||||
PasswordConfig: raw.PasswordConfig,
|
||||
Task: raw.Task,
|
||||
Organizations: raw.Organizations,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorksmobilePasswordResetPayload struct {
|
||||
Email string `json:"email"`
|
||||
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
|
||||
}
|
||||
|
||||
type WorksmobileUserOrganization struct {
|
||||
DomainID int64 `json:"domainId,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
|
||||
DomainID int64 `json:"domainId,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
LevelID string `json:"levelId,omitempty"`
|
||||
LevelName string `json:"levelName,omitempty"`
|
||||
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
|
||||
}
|
||||
|
||||
type WorksmobileUserOrgUnit struct {
|
||||
@@ -231,6 +288,10 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
levelID, levelDomainID, err := worksmobileUserLevel(user, tenantByID, rootConfig)
|
||||
if err != nil {
|
||||
return WorksmobileUserPayload{}, err
|
||||
}
|
||||
if task == "" {
|
||||
task = strings.TrimSpace(user.JobTitle)
|
||||
}
|
||||
@@ -242,6 +303,8 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
||||
CellPhone: domain.NormalizePhoneNumber(user.Phone),
|
||||
EmployeeNumber: employeeNumber,
|
||||
Locale: "ko_KR",
|
||||
LevelID: levelID,
|
||||
LevelDomainID: levelDomainID,
|
||||
Task: task,
|
||||
Organizations: organizations,
|
||||
}
|
||||
@@ -254,6 +317,7 @@ type worksmobileAppointment struct {
|
||||
IsPrimary bool
|
||||
IsManager bool
|
||||
HasManager bool
|
||||
Grade string
|
||||
JobTitle string
|
||||
PositionID string
|
||||
Source string
|
||||
@@ -267,6 +331,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: tenant.ID,
|
||||
IsPrimary: true,
|
||||
Grade: strings.TrimSpace(user.Grade),
|
||||
JobTitle: strings.TrimSpace(user.JobTitle),
|
||||
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
|
||||
}}, appointments...)
|
||||
@@ -277,6 +342,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
appointments = append([]worksmobileAppointment{{
|
||||
TenantID: accountDomainTenant.ID,
|
||||
IsPrimary: true,
|
||||
Grade: strings.TrimSpace(user.Grade),
|
||||
JobTitle: strings.TrimSpace(user.JobTitle),
|
||||
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
|
||||
}}, appointments...)
|
||||
@@ -286,6 +352,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
organizationIndexByDomainID := map[int64]int{}
|
||||
seen := map[string]bool{}
|
||||
task := ""
|
||||
fallbackOrganizationIndex := -1
|
||||
fallbackTask := ""
|
||||
primaryOrganizationIndex := -1
|
||||
primaryTask := ""
|
||||
for _, appointment := range appointments {
|
||||
if appointment.TenantID == "" || seen[appointment.TenantID] {
|
||||
continue
|
||||
@@ -303,8 +373,8 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
continue
|
||||
}
|
||||
if isWorksmobileDomainRootTenant(appointmentTenant) {
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && task == "" {
|
||||
task = strings.TrimSpace(appointment.JobTitle)
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && primaryTask == "" {
|
||||
primaryTask = strings.TrimSpace(appointment.JobTitle)
|
||||
}
|
||||
seen[appointment.TenantID] = true
|
||||
continue
|
||||
@@ -317,50 +387,104 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
|
||||
levelID, levelName := worksmobileOrganizationLevelForAppointment(appointment, tenantByID)
|
||||
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
|
||||
orgUnit := WorksmobileUserOrgUnit{
|
||||
OrgUnitID: "externalKey:" + appointmentTenant.ID,
|
||||
Primary: !organizationExists,
|
||||
PositionID: appointment.PositionID,
|
||||
}
|
||||
if appointment.IsPrimary {
|
||||
orgUnit.Primary = true
|
||||
}
|
||||
if appointment.HasManager {
|
||||
isManager := appointment.IsManager
|
||||
orgUnit.IsManager = &isManager
|
||||
}
|
||||
if organizationExists {
|
||||
if isPrimaryOrganization {
|
||||
organizations[organizationIndex].Primary = true
|
||||
organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
|
||||
if appointment.IsPrimary {
|
||||
for index := range organizations[organizationIndex].OrgUnits {
|
||||
organizations[organizationIndex].OrgUnits[index].Primary = false
|
||||
}
|
||||
}
|
||||
worksmobileApplyOrganizationLevel(&organizations[organizationIndex], levelID, levelName, appointment.IsPrimary)
|
||||
organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
|
||||
} else {
|
||||
organizationIndexByDomainID[domainID] = len(organizations)
|
||||
organizationIndex = len(organizations)
|
||||
organizations = append(organizations, WorksmobileUserOrganization{
|
||||
DomainID: domainID,
|
||||
Email: worksmobileOrganizationEmail(user, domainTenant),
|
||||
Primary: isPrimaryOrganization,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||
DomainID: domainID,
|
||||
Email: worksmobileOrganizationEmail(user, domainTenant),
|
||||
LevelID: levelID,
|
||||
LevelName: levelName,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
|
||||
})
|
||||
}
|
||||
if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
task = strings.TrimSpace(appointment.JobTitle)
|
||||
if fallbackOrganizationIndex == -1 {
|
||||
fallbackOrganizationIndex = organizationIndex
|
||||
}
|
||||
if fallbackTask == "" && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
fallbackTask = strings.TrimSpace(appointment.JobTitle)
|
||||
}
|
||||
if appointment.IsPrimary && primaryOrganizationIndex == -1 {
|
||||
primaryOrganizationIndex = organizationIndex
|
||||
}
|
||||
if appointment.IsPrimary && primaryTask == "" && strings.TrimSpace(appointment.JobTitle) != "" {
|
||||
primaryTask = strings.TrimSpace(appointment.JobTitle)
|
||||
}
|
||||
seen[appointment.TenantID] = true
|
||||
}
|
||||
if len(organizations) == 0 {
|
||||
if primaryTask != "" {
|
||||
task = primaryTask
|
||||
} else {
|
||||
task = fallbackTask
|
||||
}
|
||||
return nil, task, nil
|
||||
}
|
||||
if !worksmobileOrganizationsHavePrimary(organizations) {
|
||||
organizations[0].Primary = true
|
||||
if len(organizations[0].OrgUnits) > 0 {
|
||||
organizations[0].OrgUnits[0].Primary = true
|
||||
}
|
||||
selectedOrganizationIndex := primaryOrganizationIndex
|
||||
if selectedOrganizationIndex == -1 {
|
||||
selectedOrganizationIndex = fallbackOrganizationIndex
|
||||
}
|
||||
if selectedOrganizationIndex == -1 {
|
||||
selectedOrganizationIndex = 0
|
||||
}
|
||||
for index := range organizations {
|
||||
organizations[index].Primary = index == selectedOrganizationIndex
|
||||
}
|
||||
if len(organizations[selectedOrganizationIndex].OrgUnits) > 0 && !worksmobileOrgUnitsHavePrimary(organizations[selectedOrganizationIndex].OrgUnits) {
|
||||
organizations[selectedOrganizationIndex].OrgUnits[0].Primary = true
|
||||
}
|
||||
if primaryTask != "" {
|
||||
task = primaryTask
|
||||
} else {
|
||||
task = fallbackTask
|
||||
}
|
||||
sortWorksmobileOrganizations(organizations)
|
||||
return organizations, task, nil
|
||||
}
|
||||
|
||||
func worksmobileOrganizationLevelForAppointment(appointment worksmobileAppointment, tenantByID map[string]domain.Tenant) (string, string) {
|
||||
levelID := worksmobileLevelIDForTenant(appointment.Grade, appointment.TenantID, tenantByID)
|
||||
if levelID == "" {
|
||||
return "", ""
|
||||
}
|
||||
if isWorksmobileExternalKeyLevelID(levelID) {
|
||||
return levelID, WorksmobileLevelDisplayNameForIdentifier(levelID)
|
||||
}
|
||||
return "", levelID
|
||||
}
|
||||
|
||||
func worksmobileApplyOrganizationLevel(organization *WorksmobileUserOrganization, levelID, levelName string, prefer bool) {
|
||||
if organization == nil || (strings.TrimSpace(levelID) == "" && strings.TrimSpace(levelName) == "") {
|
||||
return
|
||||
}
|
||||
if (strings.TrimSpace(organization.LevelID) == "" && strings.TrimSpace(organization.LevelName) == "") || prefer {
|
||||
organization.LevelID = levelID
|
||||
organization.LevelName = levelName
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment, tenantID string) bool {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" {
|
||||
@@ -466,6 +590,15 @@ func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganiza
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileOrgUnitsHavePrimary(orgUnits []WorksmobileUserOrgUnit) bool {
|
||||
for _, orgUnit := range orgUnits {
|
||||
if orgUnit.Primary {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
|
||||
rawAppointments, ok := metadata["additionalAppointments"].([]any)
|
||||
if !ok {
|
||||
@@ -480,6 +613,7 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
|
||||
appointment := worksmobileAppointment{
|
||||
TenantID: metadataString(domain.JSONMap(item), "tenantId", "tenant_id"),
|
||||
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
|
||||
Grade: metadataString(domain.JSONMap(item), "grade"),
|
||||
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
|
||||
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
|
||||
Source: metadataString(domain.JSONMap(item), "assignmentSource", "source"),
|
||||
@@ -493,6 +627,193 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
|
||||
return appointments
|
||||
}
|
||||
|
||||
func worksmobileUserGrade(user domain.User) string {
|
||||
grade, _ := worksmobileUserGradeWithTenant(user)
|
||||
return grade
|
||||
}
|
||||
|
||||
func worksmobileUserLevel(user domain.User, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (string, int64, error) {
|
||||
grade, tenantID := worksmobileUserGradeWithTenant(user)
|
||||
grade = worksmobileLevelIDForTenant(grade, tenantID, tenantByID)
|
||||
if grade == "" {
|
||||
return "", 0, nil
|
||||
}
|
||||
tenant, ok := tenantByID[strings.TrimSpace(tenantID)]
|
||||
if !ok {
|
||||
return grade, 0, nil
|
||||
}
|
||||
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
|
||||
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return grade, domainID, nil
|
||||
}
|
||||
|
||||
func worksmobileUserGradeWithTenant(user domain.User) (string, string) {
|
||||
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
||||
for _, appointment := range appointments {
|
||||
if appointment.IsPrimary && strings.TrimSpace(appointment.Grade) != "" {
|
||||
return strings.TrimSpace(appointment.Grade), strings.TrimSpace(appointment.TenantID)
|
||||
}
|
||||
}
|
||||
for _, appointment := range appointments {
|
||||
if strings.TrimSpace(appointment.Grade) != "" {
|
||||
return strings.TrimSpace(appointment.Grade), strings.TrimSpace(appointment.TenantID)
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
const worksmobileExternalKeyLevelIDPrefix = "externalKey:"
|
||||
|
||||
type worksmobileGPDTDCLevelMapping struct {
|
||||
DisplayName string
|
||||
ExternalKey string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
var worksmobileGPDTDCLevelMappings = []worksmobileGPDTDCLevelMapping{
|
||||
{DisplayName: "사장", ExternalKey: "pres", Aliases: []string{"사장"}},
|
||||
{DisplayName: "부사장", ExternalKey: "vp", Aliases: []string{"부사장"}},
|
||||
{DisplayName: "수석 연구원", ExternalKey: "prin", Aliases: []string{"수석", "수석연구원", "수석 연구원"}},
|
||||
{DisplayName: "책임 연구원", ExternalKey: "lead", Aliases: []string{"책임", "책임연구원", "책임 연구원"}},
|
||||
{DisplayName: "선임 연구원", ExternalKey: "sen", Aliases: []string{"선임", "선임연구원", "선임 연구원"}},
|
||||
{DisplayName: "연구원", ExternalKey: "res", Aliases: []string{"연구원"}},
|
||||
}
|
||||
|
||||
func normalizeWorksmobileGradeForTenant(grade, tenantID string, tenantByID map[string]domain.Tenant) string {
|
||||
grade = strings.TrimSpace(grade)
|
||||
if grade == "" {
|
||||
return ""
|
||||
}
|
||||
if directorLevel := normalizeWorksmobileDirectorLevelName(grade); directorLevel != "" {
|
||||
return directorLevel
|
||||
}
|
||||
if !worksmobileTenantIsGPDTDCDescendant(tenantID, tenantByID) {
|
||||
return grade
|
||||
}
|
||||
if level, ok := worksmobileGPDTDCLevelMappingForGrade(grade); ok {
|
||||
return level.DisplayName
|
||||
}
|
||||
return grade
|
||||
}
|
||||
|
||||
func normalizeWorksmobileDirectorLevelName(grade string) string {
|
||||
switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
|
||||
case "상무":
|
||||
return "상무이사"
|
||||
case "전무":
|
||||
return "전무이사"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileLevelIDForTenant(grade, tenantID string, tenantByID map[string]domain.Tenant) string {
|
||||
displayName := normalizeWorksmobileGradeForTenant(grade, tenantID, tenantByID)
|
||||
if displayName == "" || !worksmobileTenantIsGPDTDCDescendant(tenantID, tenantByID) {
|
||||
return displayName
|
||||
}
|
||||
if level, ok := worksmobileGPDTDCLevelMappingForGrade(displayName); ok {
|
||||
return worksmobileExternalKeyLevelID(level.ExternalKey)
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
func worksmobileExternalKeyLevelID(externalKey string) string {
|
||||
externalKey = strings.TrimSpace(externalKey)
|
||||
if externalKey == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(externalKey, worksmobileExternalKeyLevelIDPrefix) {
|
||||
return externalKey
|
||||
}
|
||||
return worksmobileExternalKeyLevelIDPrefix + externalKey
|
||||
}
|
||||
|
||||
func isWorksmobileExternalKeyLevelID(levelID string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(levelID), worksmobileExternalKeyLevelIDPrefix)
|
||||
}
|
||||
|
||||
func worksmobileGPDTDCLevelMappingForGrade(grade string) (worksmobileGPDTDCLevelMapping, bool) {
|
||||
compact := strings.ReplaceAll(strings.TrimSpace(grade), " ", "")
|
||||
if compact == "" {
|
||||
return worksmobileGPDTDCLevelMapping{}, false
|
||||
}
|
||||
for _, level := range worksmobileGPDTDCLevelMappings {
|
||||
for _, alias := range level.Aliases {
|
||||
if strings.ReplaceAll(strings.TrimSpace(alias), " ", "") == compact {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return worksmobileGPDTDCLevelMapping{}, false
|
||||
}
|
||||
|
||||
func worksmobileGPDTDCLevelMappingForExternalKey(levelID string) (worksmobileGPDTDCLevelMapping, bool) {
|
||||
key := strings.TrimSpace(levelID)
|
||||
key = strings.TrimPrefix(key, worksmobileExternalKeyLevelIDPrefix)
|
||||
if key == "" {
|
||||
return worksmobileGPDTDCLevelMapping{}, false
|
||||
}
|
||||
for _, level := range worksmobileGPDTDCLevelMappings {
|
||||
if level.ExternalKey == key {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
return worksmobileGPDTDCLevelMapping{}, false
|
||||
}
|
||||
|
||||
func WorksmobileLevelDisplayNameForIdentifier(levelID string) string {
|
||||
levelID = strings.TrimSpace(levelID)
|
||||
if levelID == "" {
|
||||
return ""
|
||||
}
|
||||
if level, ok := worksmobileGPDTDCLevelMappingForExternalKey(levelID); ok {
|
||||
return level.DisplayName
|
||||
}
|
||||
return levelID
|
||||
}
|
||||
|
||||
func WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName string) bool {
|
||||
expectedLevelID = strings.TrimSpace(expectedLevelID)
|
||||
remoteLevelID = strings.TrimSpace(remoteLevelID)
|
||||
remoteLevelName = strings.TrimSpace(remoteLevelName)
|
||||
if expectedLevelID == "" {
|
||||
return remoteLevelID == "" && remoteLevelName == ""
|
||||
}
|
||||
if remoteLevelID == expectedLevelID || remoteLevelName == expectedLevelID {
|
||||
return true
|
||||
}
|
||||
if worksmobileDirectorLevelNamesEquivalent(expectedLevelID, remoteLevelName) {
|
||||
return true
|
||||
}
|
||||
if level, ok := worksmobileGPDTDCLevelMappingForExternalKey(expectedLevelID); ok {
|
||||
if remoteLevelID == level.ExternalKey || remoteLevelName == level.DisplayName {
|
||||
return true
|
||||
}
|
||||
for _, alias := range level.Aliases {
|
||||
if strings.TrimSpace(alias) == remoteLevelName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileDirectorLevelNamesEquivalent(expectedLevelName, remoteLevelName string) bool {
|
||||
expectedLevelName = strings.ReplaceAll(strings.TrimSpace(expectedLevelName), " ", "")
|
||||
remoteLevelName = strings.ReplaceAll(strings.TrimSpace(remoteLevelName), " ", "")
|
||||
if expectedLevelName == "" || remoteLevelName == "" {
|
||||
return false
|
||||
}
|
||||
return (expectedLevelName == "상무이사" && remoteLevelName == "상무") ||
|
||||
(expectedLevelName == "상무" && remoteLevelName == "상무이사") ||
|
||||
(expectedLevelName == "전무이사" && remoteLevelName == "전무") ||
|
||||
(expectedLevelName == "전무" && remoteLevelName == "전무이사")
|
||||
}
|
||||
|
||||
func sortWorksmobileOrganizations(organizations []WorksmobileUserOrganization) {
|
||||
sort.SliceStable(organizations, func(i, j int) bool {
|
||||
if organizations[i].Primary != organizations[j].Primary {
|
||||
|
||||
@@ -91,11 +91,18 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
Email: "john1@samaneng.com",
|
||||
Name: "John Doe",
|
||||
Phone: "+19144812222",
|
||||
Position: "Manager",
|
||||
Position: "Team Lead",
|
||||
JobTitle: "Sales management",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"employee_id": "AB001",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "Manager",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
@@ -138,6 +145,7 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
|
||||
require.Equal(t, "+19144812222", payload.CellPhone)
|
||||
require.Equal(t, "AB001", payload.EmployeeNumber)
|
||||
require.Equal(t, "Sales management", payload.Task)
|
||||
require.Equal(t, "Manager", payload.LevelID)
|
||||
require.Empty(t, payload.PrivateEmail)
|
||||
require.Empty(t, payload.AliasEmails)
|
||||
require.Equal(t, "ko_KR", payload.Locale)
|
||||
@@ -172,6 +180,71 @@ func TestBuildWorksmobileUserPayloadDeduplicatesKoreanCountryCodeInCellPhone(t *
|
||||
require.Equal(t, "+821091917771", payload.CellPhone)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadIgnoresTopLevelUserGrade(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "john1@samaneng.com",
|
||||
Name: "John Doe",
|
||||
Grade: "책임",
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, payload.LevelID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadNormalizesDirectorLevelNames(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
tenantID := "33333333-3333-3333-3333-333333333333"
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
grade string
|
||||
expected string
|
||||
}{
|
||||
{grade: "상무", expected: "상무이사"},
|
||||
{grade: "전무", expected: "전무이사"},
|
||||
} {
|
||||
t.Run(tc.grade, func(t *testing.T) {
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "director@samaneng.com",
|
||||
Name: "Director",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": tc.grade,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, payload.LevelID)
|
||||
require.Equal(t, int64(1001), payload.LevelDomainID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONOmitsEmptyPasswordConfig(t *testing.T) {
|
||||
data, err := json.Marshal(WorksmobileUserPayload{
|
||||
DomainID: 1001,
|
||||
@@ -315,22 +388,183 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "PM", payload.Task)
|
||||
require.Equal(t, "Engineering", payload.Task)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
|
||||
require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
|
||||
require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
|
||||
func TestBuildWorksmobileUserPayloadMapsAppointmentGradeToOrganizationLevelName(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
tenantID := "22222222-2222-2222-2222-222222222222"
|
||||
user := domain.User{
|
||||
ID: "33333333-3333-3333-3333-333333333333",
|
||||
Email: "principal@samaneng.com",
|
||||
Name: "Principal Researcher",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "수석 연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "general-structure-div",
|
||||
Name: "일반구조물 div",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
tenant,
|
||||
map[string]domain.Tenant{
|
||||
rootID: rootTenant,
|
||||
tenantID: tenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
|
||||
require.Empty(t, payload.Organizations[0].LevelID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesPrimaryAppointmentGradeForOrganizationLevel(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
firstTenantID := "22222222-2222-2222-2222-222222222222"
|
||||
primaryTenantID := "33333333-3333-3333-3333-333333333333"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "primary-grade@samaneng.com",
|
||||
Name: "Primary Grade User",
|
||||
TenantID: &primaryTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": firstTenantID,
|
||||
"isPrimary": false,
|
||||
"grade": "책임",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": primaryTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "수석 연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: "saman",
|
||||
Name: "삼안",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
firstTenant := domain.Tenant{
|
||||
ID: firstTenantID,
|
||||
Slug: "first-team",
|
||||
Name: "First Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
primaryTenant := domain.Tenant{
|
||||
ID: primaryTenantID,
|
||||
Slug: "primary-team",
|
||||
Name: "Primary Team",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
primaryTenant,
|
||||
map[string]domain.Tenant{
|
||||
rootID: rootTenant,
|
||||
firstTenantID: firstTenant,
|
||||
primaryTenantID: primaryTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
|
||||
require.Empty(t, payload.Organizations[0].LevelID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadMapsGPDTDCAppointmentGradeToOrganizationLevelID(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
tenantID := "22222222-2222-2222-2222-222222222222"
|
||||
user := domain.User{
|
||||
ID: "33333333-3333-3333-3333-333333333333",
|
||||
Email: "principal@baroncs.co.kr",
|
||||
Name: "GPDTDC Principal",
|
||||
TenantID: &tenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "수석 연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
|
||||
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "gsim-dev",
|
||||
Name: "GSIM개발",
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &gpdtdcID,
|
||||
}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
tenant,
|
||||
map[string]domain.Tenant{
|
||||
rootID: rootTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
tenantID: tenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, "externalKey:prin", payload.Organizations[0].LevelID)
|
||||
require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesPrimaryAppointmentAsWorksmobilePrimary(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
hanmacRootID := "11111111-1111-1111-1111-111111111111"
|
||||
@@ -398,11 +632,11 @@ func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePri
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, payload.Organizations, 2)
|
||||
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
|
||||
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
|
||||
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
||||
require.False(t, payload.Organizations[1].Primary)
|
||||
}
|
||||
|
||||
@@ -626,6 +860,7 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
|
||||
map[string]any{
|
||||
"tenantId": leafTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "수석",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -661,6 +896,8 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1001), payload.DomainID)
|
||||
require.Equal(t, "externalKey:prin", payload.LevelID)
|
||||
require.Equal(t, int64(1003), payload.LevelDomainID)
|
||||
require.Len(t, payload.Organizations, 1)
|
||||
require.Equal(t, int64(1003), payload.Organizations[0].DomainID)
|
||||
require.True(t, payload.Organizations[0].Primary)
|
||||
@@ -669,6 +906,137 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
|
||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadNormalizesGPDTDCResearchLevelName(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "researcher@baroncs.co.kr",
|
||||
Name: "GPDTDC Researcher",
|
||||
TenantID: &leafTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": leafTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "책임연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
|
||||
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
|
||||
leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
leafTenant,
|
||||
map[string]domain.Tenant{
|
||||
rootID: rootTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
leafTenantID: leafTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "externalKey:lead", payload.LevelID)
|
||||
require.Equal(t, int64(1003), payload.LevelDomainID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesGPDLevelCSVExternalKeyForPresident(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "president@baroncs.co.kr",
|
||||
Name: "GPDTDC President",
|
||||
TenantID: &leafTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": leafTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "사장",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
|
||||
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
|
||||
leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
leafTenant,
|
||||
map[string]domain.Tenant{
|
||||
rootID: rootTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
leafTenantID: leafTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "externalKey:pres", payload.LevelID)
|
||||
require.Equal(t, int64(1003), payload.LevelDomainID)
|
||||
}
|
||||
|
||||
func TestBuildWorksmobileUserPayloadUsesGPDTDCDirectorLevelNameWithoutExternalKey(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "11111111-1111-1111-1111-111111111111"
|
||||
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
|
||||
leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
|
||||
user := domain.User{
|
||||
ID: "44444444-4444-4444-4444-444444444444",
|
||||
Email: "director@baroncs.co.kr",
|
||||
Name: "GPDTDC Director",
|
||||
TenantID: &leafTenantID,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": leafTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "전무이사",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
|
||||
gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
|
||||
leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
|
||||
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
leafTenant,
|
||||
map[string]domain.Tenant{
|
||||
rootID: rootTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
leafTenantID: leafTenant,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "전무이사", payload.LevelID)
|
||||
require.Equal(t, int64(1003), payload.LevelDomainID)
|
||||
}
|
||||
|
||||
func TestWorksmobileLevelIdentifierMatchesRemoteAcceptsGPDLevelAliases(t *testing.T) {
|
||||
require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:prin", "91515bed-0d5f-4711-78fa-03894597fd2c", "수석연구원"))
|
||||
require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:lead", "8fde782c-1a46-4bd6-7653-0344a3f66fa5", "책임연구원"))
|
||||
require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:sen", "8c272083-3cca-47a0-79e2-039cba57b2cc", "선임연구원"))
|
||||
}
|
||||
|
||||
func TestWorksmobileLevelIdentifierMatchesRemoteAcceptsDirectorLevelAliases(t *testing.T) {
|
||||
require.True(t, WorksmobileLevelIdentifierMatchesRemote("상무이사", "level-managing-director", "상무"))
|
||||
require.True(t, WorksmobileLevelIdentifierMatchesRemote("전무이사", "level-executive-director", "전무"))
|
||||
}
|
||||
|
||||
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
|
||||
payload := WorksmobileUserPayload{
|
||||
Email: "user@samaneng.com",
|
||||
|
||||
@@ -290,6 +290,9 @@ func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := target.(*WorksmobileUserPayload); ok {
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(target)
|
||||
|
||||
@@ -107,49 +107,69 @@ type WorksmobileComparison struct {
|
||||
}
|
||||
|
||||
type WorksmobileComparisonItem struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||
UpdateReasons []string `json:"updateReasons,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
BaronID string `json:"baronId,omitempty"`
|
||||
BaronSlug string `json:"baronSlug,omitempty"`
|
||||
BaronName string `json:"baronName,omitempty"`
|
||||
BaronEmail string `json:"baronEmail,omitempty"`
|
||||
BaronPhone string `json:"baronPhone,omitempty"`
|
||||
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
|
||||
BaronGrade string `json:"baronGrade,omitempty"`
|
||||
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
|
||||
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
|
||||
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
|
||||
BaronParentID string `json:"baronParentId,omitempty"`
|
||||
BaronParentSlug string `json:"baronParentSlug,omitempty"`
|
||||
BaronParentName string `json:"baronParentName,omitempty"`
|
||||
WorksmobileID string `json:"worksmobileId,omitempty"`
|
||||
ExternalKey string `json:"externalKey,omitempty"`
|
||||
WorksmobileName string `json:"worksmobileName,omitempty"`
|
||||
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
|
||||
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
|
||||
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
|
||||
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileTask string `json:"worksmobileTask,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
|
||||
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
|
||||
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
|
||||
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
|
||||
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
|
||||
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
|
||||
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
|
||||
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
|
||||
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
|
||||
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
|
||||
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
|
||||
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
|
||||
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
|
||||
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
|
||||
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
|
||||
UserMemberships []WorksmobileUserMembershipComparison `json:"userMemberships,omitempty"`
|
||||
UpdateReasons []string `json:"updateReasons,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type WorksmobileUserMembershipComparison struct {
|
||||
BaronOrgID string `json:"baronOrgId,omitempty"`
|
||||
BaronOrgSlug string `json:"baronOrgSlug,omitempty"`
|
||||
BaronOrgName string `json:"baronOrgName,omitempty"`
|
||||
BaronGrade string `json:"baronGrade,omitempty"`
|
||||
BaronPrimary bool `json:"baronPrimary,omitempty"`
|
||||
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
|
||||
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
|
||||
WorksmobileOrgID string `json:"worksmobileOrgId,omitempty"`
|
||||
WorksmobileOrgName string `json:"worksmobileOrgName,omitempty"`
|
||||
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
|
||||
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
|
||||
WorksmobileOrgPositionID string `json:"worksmobileOrgPositionId,omitempty"`
|
||||
WorksmobileOrgIsManager *bool `json:"worksmobileOrgIsManager,omitempty"`
|
||||
WorksmobilePrimary bool `json:"worksmobilePrimary,omitempty"`
|
||||
GradeNeedsUpdate bool `json:"gradeNeedsUpdate,omitempty"`
|
||||
}
|
||||
|
||||
type worksmobileSyncService struct {
|
||||
@@ -362,7 +382,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
tenantIDs = append(tenantIDs, tenant.ID)
|
||||
}
|
||||
}
|
||||
users, err := s.comparisonUsers(ctx, tenantIDs)
|
||||
users, err := s.comparisonUsers(ctx, tenantIDs, tenantByID)
|
||||
if err != nil {
|
||||
return WorksmobileComparison{}, err
|
||||
}
|
||||
@@ -383,7 +403,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
|
||||
if s.identityMirror != nil {
|
||||
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
|
||||
if err == nil &&
|
||||
@@ -394,32 +414,100 @@ func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
|
||||
return worksmobileUsersFromIdentityMirror(identities, tenantIDs, tenantByID), nil
|
||||
}
|
||||
}
|
||||
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||
}
|
||||
|
||||
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
|
||||
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string, tenantMaps ...map[string]domain.Tenant) []domain.User {
|
||||
allowed := make(map[string]bool, len(tenantIDs))
|
||||
for _, tenantID := range tenantIDs {
|
||||
allowed[strings.TrimSpace(tenantID)] = true
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
allowed[strings.ToLower(tenantID)] = true
|
||||
if len(tenantMaps) > 0 {
|
||||
if tenant, ok := tenantMaps[0][tenantID]; ok {
|
||||
if slug := strings.TrimSpace(tenant.Slug); slug != "" {
|
||||
allowed[strings.ToLower(slug)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
users := make([]domain.User, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
tenantID := traitString(identity.Traits, "tenant_id")
|
||||
if tenantID == "" || !allowed[tenantID] {
|
||||
if !worksmobileIdentityMirrorMatchesTenant(identity.Traits, allowed) {
|
||||
continue
|
||||
}
|
||||
user := worksmobileUserFromIdentity(identity)
|
||||
if user.TenantID == nil || strings.TrimSpace(*user.TenantID) == "" {
|
||||
if tenantID := worksmobileIdentityMirrorTenantID(identity.Traits, allowed); tenantID != "" {
|
||||
user.TenantID = &tenantID
|
||||
}
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func worksmobileIdentityMirrorMatchesTenant(traits map[string]any, allowed map[string]bool) bool {
|
||||
for _, key := range identityMirrorTenantKeys(traits) {
|
||||
if allowed[strings.ToLower(strings.TrimSpace(key))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileIdentityMirrorTenantID(traits map[string]any, allowed map[string]bool) string {
|
||||
appointments := identityMirrorAppointments(traits["additionalAppointments"])
|
||||
if len(appointments) == 0 {
|
||||
if metadata, ok := traits["metadata"].(map[string]any); ok {
|
||||
appointments = identityMirrorAppointments(metadata["additionalAppointments"])
|
||||
}
|
||||
}
|
||||
for _, appointment := range appointments {
|
||||
if !metadataBool(domain.JSONMap(appointment), "isPrimary", "primary") {
|
||||
continue
|
||||
}
|
||||
if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
for _, appointment := range appointments {
|
||||
if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment map[string]any, allowed map[string]bool) string {
|
||||
tenantID := strings.TrimSpace(identityMirrorAnyString(appointment["tenantId"]))
|
||||
if tenantID == "" {
|
||||
tenantID = strings.TrimSpace(identityMirrorAnyString(appointment["tenant_id"]))
|
||||
}
|
||||
if tenantID != "" && allowed[strings.ToLower(tenantID)] {
|
||||
return tenantID
|
||||
}
|
||||
tenantSlug := strings.TrimSpace(identityMirrorAnyString(appointment["tenantSlug"]))
|
||||
if tenantSlug == "" {
|
||||
tenantSlug = strings.TrimSpace(identityMirrorAnyString(appointment["slug"]))
|
||||
}
|
||||
if tenantSlug != "" && allowed[strings.ToLower(tenantSlug)] {
|
||||
return tenantID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
|
||||
metadata := domain.JSONMap{}
|
||||
for key, value := range identity.Traits {
|
||||
if key == "grade" {
|
||||
continue
|
||||
}
|
||||
metadata[key] = value
|
||||
}
|
||||
tenantID := traitString(identity.Traits, "tenant_id")
|
||||
@@ -438,7 +526,6 @@ func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
|
||||
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
|
||||
AffiliationType: traitString(identity.Traits, "affiliationType"),
|
||||
Department: traitString(identity.Traits, "department"),
|
||||
Grade: traitString(identity.Traits, "grade"),
|
||||
Position: traitString(identity.Traits, "position"),
|
||||
JobTitle: traitString(identity.Traits, "jobTitle"),
|
||||
Metadata: metadata,
|
||||
@@ -595,10 +682,8 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
|
||||
return nil, errors.New("worksmobile orgunit not found")
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok {
|
||||
return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants)
|
||||
}
|
||||
if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
|
||||
_, matchedLocalPart := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID)
|
||||
if !matchedLocalPart && isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
|
||||
return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted")
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
@@ -1622,7 +1707,9 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
|
||||
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
|
||||
}
|
||||
updateReasons := []string(nil)
|
||||
gradeComparison := worksmobileUserGradeComparison{}
|
||||
if matched {
|
||||
gradeComparison = worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
}
|
||||
needsUpdate := len(updateReasons) > 0
|
||||
@@ -1630,6 +1717,10 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
continue
|
||||
}
|
||||
baronGrade, _ := worksmobileUserComparisonGradeWithTenant(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
if strings.TrimSpace(gradeComparison.LocalGrade) != "" {
|
||||
baronGrade = gradeComparison.LocalGrade
|
||||
}
|
||||
item := WorksmobileComparisonItem{
|
||||
ResourceType: "USER",
|
||||
BaronID: user.ID,
|
||||
@@ -1637,6 +1728,7 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
|
||||
BaronEmail: user.Email,
|
||||
BaronPhone: user.Phone,
|
||||
BaronEmployeeNumber: metadataEmployeeNumber(user.Metadata),
|
||||
BaronGrade: baronGrade,
|
||||
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
|
||||
BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
|
||||
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
|
||||
@@ -1665,6 +1757,10 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
|
||||
item.WorksmobileAccountStatus = worksmobileRemoteAccountStatus(remote)
|
||||
item.WorksmobileLevelID = remote.LevelID
|
||||
item.WorksmobileLevelName = remote.LevelName
|
||||
if gradeComparison.NeedsUpdate {
|
||||
item.WorksmobileLevelID = gradeComparison.RemoteLevelID
|
||||
item.WorksmobileLevelName = gradeComparison.RemoteLevelName
|
||||
}
|
||||
item.WorksmobileTask = remote.Task
|
||||
item.WorksmobileDomainID = remote.DomainID
|
||||
item.WorksmobileDomainName = remote.DomainName
|
||||
@@ -1673,6 +1769,7 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
|
||||
item.WorksmobilePrimaryOrgPositionID = remote.PrimaryOrgUnitPositionID
|
||||
item.WorksmobilePrimaryOrgPositionName = remote.PrimaryOrgUnitPositionName
|
||||
item.WorksmobilePrimaryOrgIsManager = remote.PrimaryOrgUnitIsManager
|
||||
item.UserMemberships = worksmobileUserMembershipComparisons(user, remote, localTenants, remoteOrgUnitByExternalID, gradeComparison)
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
}
|
||||
result = append(result, item)
|
||||
@@ -1779,6 +1876,9 @@ func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser
|
||||
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
|
||||
reasons = append(reasons, "employee_number")
|
||||
}
|
||||
if worksmobileUserGradeNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
|
||||
reasons = append(reasons, "grade")
|
||||
}
|
||||
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
|
||||
reasons = append(reasons, "organization")
|
||||
}
|
||||
@@ -1823,6 +1923,229 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
|
||||
return localEmployeeNumber != remoteEmployeeNumber
|
||||
}
|
||||
|
||||
func worksmobileUserGradeNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
return worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID).NeedsUpdate
|
||||
}
|
||||
|
||||
func worksmobileUserComparisonGradeWithTenant(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) (string, string) {
|
||||
comparison := worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
|
||||
if strings.TrimSpace(comparison.LocalGrade) != "" {
|
||||
return comparison.LocalGrade, comparison.TenantID
|
||||
}
|
||||
tenantID := worksmobileRemotePrimaryTenantID(remote, localTenants, remoteOrgUnitByExternalID)
|
||||
if tenantID == "" {
|
||||
tenantID = worksmobileUserComparisonTenantID(user, localTenants)
|
||||
}
|
||||
if tenantID == "" {
|
||||
return "", ""
|
||||
}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
|
||||
if strings.TrimSpace(appointment.TenantID) != tenantID {
|
||||
continue
|
||||
}
|
||||
grade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
|
||||
if grade == "" {
|
||||
return "", tenantID
|
||||
}
|
||||
return grade, tenantID
|
||||
}
|
||||
return "", tenantID
|
||||
}
|
||||
|
||||
type worksmobileUserGradeComparison struct {
|
||||
NeedsUpdate bool
|
||||
TenantID string
|
||||
LocalGrade string
|
||||
RemoteLevelID string
|
||||
RemoteLevelName string
|
||||
}
|
||||
|
||||
type worksmobileRemoteOrganizationLevel struct {
|
||||
levelID string
|
||||
levelName string
|
||||
primary bool
|
||||
}
|
||||
|
||||
func worksmobileCompareUserGrade(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) worksmobileUserGradeComparison {
|
||||
if localTenants == nil {
|
||||
return worksmobileUserGradeComparison{}
|
||||
}
|
||||
remoteLevelsByTenant := worksmobileRemoteOrganizationLevelsByTenant(remote, localTenants, remoteOrgUnitByExternalID)
|
||||
if len(remoteLevelsByTenant) == 0 {
|
||||
return worksmobileUserGradeComparison{}
|
||||
}
|
||||
fallback := worksmobileUserGradeComparison{}
|
||||
for _, appointment := range worksmobileGradeComparisonAppointments(user, localTenants) {
|
||||
tenantID := strings.TrimSpace(appointment.TenantID)
|
||||
localGrade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
|
||||
if tenantID == "" || localGrade == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := localTenants[tenantID]; !ok {
|
||||
continue
|
||||
}
|
||||
remoteLevel, ok := remoteLevelsByTenant[tenantID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
comparison := worksmobileUserGradeComparison{
|
||||
TenantID: tenantID,
|
||||
LocalGrade: localGrade,
|
||||
RemoteLevelID: strings.TrimSpace(remoteLevel.levelID),
|
||||
RemoteLevelName: strings.TrimSpace(remoteLevel.levelName),
|
||||
}
|
||||
if fallback.LocalGrade == "" || remoteLevel.primary {
|
||||
fallback = comparison
|
||||
}
|
||||
if comparison.RemoteLevelName == "" && comparison.RemoteLevelID == "" {
|
||||
comparison.NeedsUpdate = true
|
||||
return comparison
|
||||
}
|
||||
if !worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, comparison.RemoteLevelID, comparison.RemoteLevelName, localTenants) {
|
||||
comparison.NeedsUpdate = true
|
||||
return comparison
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, remoteLevelID, remoteLevelName string, localTenants map[string]domain.Tenant) bool {
|
||||
localGrade = strings.TrimSpace(localGrade)
|
||||
remoteLevelID = strings.TrimSpace(remoteLevelID)
|
||||
remoteLevelName = strings.TrimSpace(remoteLevelName)
|
||||
if localGrade == "" {
|
||||
return remoteLevelID == "" && remoteLevelName == ""
|
||||
}
|
||||
if remoteLevelName == localGrade || remoteLevelID == localGrade {
|
||||
return true
|
||||
}
|
||||
expectedLevelID := worksmobileLevelIDForTenant(localGrade, tenantID, localTenants)
|
||||
return WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName)
|
||||
}
|
||||
|
||||
func worksmobileGradeComparisonAppointments(user domain.User, tenantByID map[string]domain.Tenant) []worksmobileAppointment {
|
||||
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
|
||||
hasGPDTDCGrade := false
|
||||
for _, appointment := range appointments {
|
||||
if strings.TrimSpace(appointment.Grade) == "" {
|
||||
continue
|
||||
}
|
||||
if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
|
||||
hasGPDTDCGrade = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGPDTDCGrade {
|
||||
return appointments
|
||||
}
|
||||
filtered := make([]worksmobileAppointment, 0, len(appointments))
|
||||
for _, appointment := range appointments {
|
||||
if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
|
||||
filtered = append(filtered, appointment)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func worksmobileRemoteOrganizationLevelsByTenant(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteOrganizationLevel {
|
||||
result := map[string]worksmobileRemoteOrganizationLevel{}
|
||||
if localTenants == nil {
|
||||
return result
|
||||
}
|
||||
organizations := remote.Organizations
|
||||
if len(organizations) == 0 {
|
||||
organizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
|
||||
} else {
|
||||
organizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
|
||||
}
|
||||
for _, organization := range organizations {
|
||||
levelID := strings.TrimSpace(organization.LevelID)
|
||||
levelName := strings.TrimSpace(organization.LevelName)
|
||||
if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
|
||||
levelID = strings.TrimSpace(remote.LevelID)
|
||||
levelName = strings.TrimSpace(remote.LevelName)
|
||||
}
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
tenantID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := localTenants[tenantID]; !ok {
|
||||
continue
|
||||
}
|
||||
current := worksmobileRemoteOrganizationLevel{
|
||||
levelID: levelID,
|
||||
levelName: levelName,
|
||||
primary: organization.Primary || orgUnit.Primary,
|
||||
}
|
||||
existing, ok := result[tenantID]
|
||||
if !ok || (!existing.primary && current.primary) {
|
||||
result[tenantID] = current
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemotePrimaryTenantID(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
|
||||
if localTenants == nil {
|
||||
return ""
|
||||
}
|
||||
for _, orgUnitID := range worksmobileRemotePrimaryOrgUnitIDs(remote) {
|
||||
canonicalOrgUnitID := worksmobileCanonicalRemoteOrgUnitID(orgUnitID, remoteOrgUnitByExternalID)
|
||||
tenantID := worksmobileOrgUnitLocalExternalKey(canonicalOrgUnitID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := localTenants[tenantID]; ok {
|
||||
return tenantID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func worksmobileIsResearchGrade(values ...string) bool {
|
||||
for _, value := range values {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalized, "연구원") ||
|
||||
strings.Contains(normalized, "선임") ||
|
||||
strings.Contains(normalized, "책임") ||
|
||||
strings.Contains(normalized, "수석") ||
|
||||
strings.Contains(normalized, "research") ||
|
||||
strings.Contains(normalized, "principal") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileTenantIsGPDTDCDescendant(tenantID string, tenantByID map[string]domain.Tenant) bool {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" || tenantByID == nil {
|
||||
return false
|
||||
}
|
||||
visited := map[string]bool{}
|
||||
currentID := tenantID
|
||||
for currentID != "" {
|
||||
if visited[currentID] {
|
||||
return false
|
||||
}
|
||||
visited[currentID] = true
|
||||
tenant, ok := tenantByID[currentID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if worksmobileTenantDomainIDEnvKey(tenant) == "GPDTDC_DOMAIN_ID" && isWorksmobileDomainRootTenant(tenant) {
|
||||
return true
|
||||
}
|
||||
currentID = worksmobileTenantParentID(tenant)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
|
||||
if localTenants == nil {
|
||||
return false
|
||||
@@ -1911,9 +2234,11 @@ func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remo
|
||||
}
|
||||
return []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: remote.DomainID,
|
||||
Email: strings.TrimSpace(remote.Email),
|
||||
Primary: true,
|
||||
DomainID: remote.DomainID,
|
||||
Email: strings.TrimSpace(remote.Email),
|
||||
Primary: true,
|
||||
LevelID: strings.TrimSpace(remote.LevelID),
|
||||
LevelName: strings.TrimSpace(remote.LevelName),
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
|
||||
@@ -2009,20 +2334,17 @@ type worksmobileComparableOrgUnit struct {
|
||||
func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, remote []WorksmobileUserOrganization) bool {
|
||||
expectedUnits := flattenExpectedWorksmobileUserOrganizations(expected)
|
||||
remoteUnits := flattenRemoteWorksmobileUserOrganizations(remote)
|
||||
if len(expectedUnits) != len(remoteUnits) {
|
||||
if len(expectedUnits) == 0 {
|
||||
return len(remoteUnits) == 0
|
||||
}
|
||||
if len(remoteUnits) == 0 {
|
||||
return false
|
||||
}
|
||||
for key, expectedUnit := range expectedUnits {
|
||||
remoteUnit, ok := remoteUnits[key]
|
||||
for key, remoteUnit := range remoteUnits {
|
||||
expectedUnit, ok := expectedUnits[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
|
||||
return false
|
||||
}
|
||||
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
|
||||
return false
|
||||
}
|
||||
if expectedUnit.comparePosition && strings.TrimSpace(expectedUnit.positionID) != strings.TrimSpace(remoteUnit.positionID) {
|
||||
return false
|
||||
}
|
||||
@@ -2090,6 +2412,145 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
|
||||
return result
|
||||
}
|
||||
|
||||
type worksmobileRemoteMembershipDetail struct {
|
||||
domainID int64
|
||||
domainName string
|
||||
orgUnitID string
|
||||
orgUnitName string
|
||||
levelID string
|
||||
levelName string
|
||||
positionID string
|
||||
manager *bool
|
||||
primary bool
|
||||
}
|
||||
|
||||
func worksmobileUserMembershipComparisons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup, gradeComparison worksmobileUserGradeComparison) []WorksmobileUserMembershipComparison {
|
||||
if localTenants == nil {
|
||||
return nil
|
||||
}
|
||||
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
|
||||
if tenantID == "" {
|
||||
return nil
|
||||
}
|
||||
tenant, ok := localTenants[tenantID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
|
||||
if err != nil || len(expectedOrganizations) == 0 {
|
||||
return nil
|
||||
}
|
||||
remoteOrganizations := remote.Organizations
|
||||
if len(remoteOrganizations) == 0 {
|
||||
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
|
||||
} else {
|
||||
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
|
||||
}
|
||||
remoteMemberships := worksmobileRemoteMembershipDetailsByKey(remote, remoteOrganizations, remoteOrgUnitByExternalID)
|
||||
appointments := worksmobileAppointmentsByTenantID(user.Metadata)
|
||||
result := make([]WorksmobileUserMembershipComparison, 0)
|
||||
for _, organization := range expectedOrganizations {
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
baronOrgID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
|
||||
if baronOrgID == "" {
|
||||
continue
|
||||
}
|
||||
baronTenant, ok := localTenants[baronOrgID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := WorksmobileUserMembershipComparison{
|
||||
BaronOrgID: baronOrgID,
|
||||
BaronOrgSlug: strings.TrimSpace(baronTenant.Slug),
|
||||
BaronOrgName: strings.TrimSpace(baronTenant.Name),
|
||||
BaronPrimary: organization.Primary || orgUnit.Primary,
|
||||
}
|
||||
if appointment, ok := appointments[baronOrgID]; ok {
|
||||
item.BaronGrade = normalizeWorksmobileGradeForTenant(appointment.Grade, baronOrgID, localTenants)
|
||||
}
|
||||
key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID)
|
||||
if remoteMembership, ok := remoteMemberships[key]; ok {
|
||||
item.WorksmobileDomainID = remoteMembership.domainID
|
||||
item.WorksmobileDomainName = remoteMembership.domainName
|
||||
item.WorksmobileOrgID = remoteMembership.orgUnitID
|
||||
item.WorksmobileOrgName = remoteMembership.orgUnitName
|
||||
item.WorksmobileLevelID = remoteMembership.levelID
|
||||
item.WorksmobileLevelName = remoteMembership.levelName
|
||||
item.WorksmobileOrgPositionID = remoteMembership.positionID
|
||||
item.WorksmobileOrgIsManager = remoteMembership.manager
|
||||
item.WorksmobilePrimary = remoteMembership.primary
|
||||
}
|
||||
item.GradeNeedsUpdate = gradeComparison.NeedsUpdate && strings.TrimSpace(gradeComparison.TenantID) == baronOrgID
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileAppointmentsByTenantID(metadata domain.JSONMap) map[string]worksmobileAppointment {
|
||||
result := map[string]worksmobileAppointment{}
|
||||
for _, appointment := range worksmobileAppointmentsFromMetadata(metadata) {
|
||||
tenantID := strings.TrimSpace(appointment.TenantID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
result[tenantID] = appointment
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileRemoteMembershipDetailsByKey(remote WorksmobileRemoteUser, organizations []WorksmobileUserOrganization, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteMembershipDetail {
|
||||
result := map[string]worksmobileRemoteMembershipDetail{}
|
||||
for _, organization := range organizations {
|
||||
domainID := organization.DomainID
|
||||
if domainID == 0 {
|
||||
domainID = remote.DomainID
|
||||
}
|
||||
domainName := strings.TrimSpace(remote.DomainName)
|
||||
levelID := strings.TrimSpace(organization.LevelID)
|
||||
levelName := strings.TrimSpace(organization.LevelName)
|
||||
if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
|
||||
levelID = strings.TrimSpace(remote.LevelID)
|
||||
levelName = strings.TrimSpace(remote.LevelName)
|
||||
}
|
||||
for _, orgUnit := range organization.OrgUnits {
|
||||
key := worksmobileComparableOrgUnitKey(domainID, orgUnit.OrgUnitID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
localExternalKey := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
|
||||
orgUnitID := strings.TrimSpace(orgUnit.OrgUnitID)
|
||||
orgUnitName := ""
|
||||
if localExternalKey != "" {
|
||||
if remoteGroup, ok := remoteOrgUnitByExternalID[localExternalKey]; ok {
|
||||
if strings.TrimSpace(remoteGroup.ID) != "" {
|
||||
orgUnitID = strings.TrimSpace(remoteGroup.ID)
|
||||
}
|
||||
orgUnitName = strings.TrimSpace(remoteGroup.DisplayName)
|
||||
if domainName == "" {
|
||||
domainName = strings.TrimSpace(remoteGroup.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if orgUnitName == "" && worksmobileOrgUnitIDContains([]string{remote.PrimaryOrgUnitID}, orgUnitID) {
|
||||
orgUnitName = strings.TrimSpace(remote.PrimaryOrgUnitName)
|
||||
}
|
||||
result[key] = worksmobileRemoteMembershipDetail{
|
||||
domainID: domainID,
|
||||
domainName: domainName,
|
||||
orgUnitID: orgUnitID,
|
||||
orgUnitName: orgUnitName,
|
||||
levelID: levelID,
|
||||
levelName: levelName,
|
||||
positionID: strings.TrimSpace(orgUnit.PositionID),
|
||||
manager: orgUnit.IsManager,
|
||||
primary: organization.Primary || orgUnit.Primary,
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func worksmobileComparableOrgUnitKey(domainID int64, orgUnitID string) string {
|
||||
orgUnitID = strings.TrimSpace(orgUnitID)
|
||||
if domainID == 0 || orgUnitID == "" {
|
||||
@@ -2167,10 +2628,7 @@ func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrgID(user domain.User) string {
|
||||
if user.TenantID == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*user.TenantID)
|
||||
return worksmobileUserComparisonPrimaryTenantID(user)
|
||||
}
|
||||
|
||||
func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]domain.Tenant) string {
|
||||
|
||||
@@ -1200,7 +1200,7 @@ func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(
|
||||
require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) {
|
||||
func TestWorksmobileSyncServiceDeletesWorksOrgUnitEvenWhenSlugLocalPartMatches(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
orgID := "baron-org-1"
|
||||
@@ -1244,11 +1244,10 @@ func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *test
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
|
||||
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.Equal(t, orgID, request.OrgUnitExternalKey)
|
||||
require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
|
||||
require.Equal(t, "works-org-1", outboxRepo.created[0].ResourceID)
|
||||
require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"])
|
||||
require.Equal(t, "legacy-external-key", outboxRepo.created[0].Payload["externalKey"])
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) {
|
||||
@@ -1342,11 +1341,10 @@ func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtected
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
|
||||
require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
|
||||
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
|
||||
require.Equal(t, orgID, request.OrgUnitExternalKey)
|
||||
require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"])
|
||||
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
|
||||
require.Equal(t, "works-operations", outboxRepo.created[0].ResourceID)
|
||||
require.Equal(t, "works-operations", outboxRepo.created[0].Payload["worksmobileId"])
|
||||
require.Equal(t, "legacy-operations-id", outboxRepo.created[0].Payload["externalKey"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) {
|
||||
@@ -2172,6 +2170,403 @@ func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksTenantLinkedGradeChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
tenantID := "tenant-gpdtdc-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-grade",
|
||||
Email: "grade@samaneng.com",
|
||||
Name: "Grade User",
|
||||
TenantID: &tenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "책임",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-grade",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
LevelName: "",
|
||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + tenantID, Primary: true}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Contains(t, items[0].UpdateReasons, "grade")
|
||||
require.Equal(t, "책임 연구원", items[0].BaronGrade)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIncludesMembershipMatchForGradeUpdate(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
hmegID := "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f"
|
||||
user := domain.User{
|
||||
ID: "user-hmeg-researcher",
|
||||
Email: "hmeg-researcher@baroncs.co.kr",
|
||||
Name: "HMEG Researcher",
|
||||
TenantID: &hmegID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": hmegID,
|
||||
"isPrimary": true,
|
||||
"grade": "책임연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsersWithRemoteGroups(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-hmeg-researcher",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1003,
|
||||
PrimaryOrgUnitID: "works-hmeg",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "works-hmeg", Primary: true}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
hmegID: {ID: hmegID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
[]WorksmobileRemoteGroup{{
|
||||
ID: "works-hmeg",
|
||||
ExternalID: hmegID,
|
||||
DisplayName: "WORKS HmEG",
|
||||
DomainID: 1003,
|
||||
DomainName: "baroncs.co.kr",
|
||||
}},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Contains(t, items[0].UpdateReasons, "grade")
|
||||
require.Len(t, items[0].UserMemberships, 1)
|
||||
require.Equal(t, hmegID, items[0].UserMemberships[0].BaronOrgID)
|
||||
require.Equal(t, "HmEG", items[0].UserMemberships[0].BaronOrgName)
|
||||
require.Equal(t, "hmeg", items[0].UserMemberships[0].BaronOrgSlug)
|
||||
require.Equal(t, "책임 연구원", items[0].UserMemberships[0].BaronGrade)
|
||||
require.Equal(t, "works-hmeg", items[0].UserMemberships[0].WorksmobileOrgID)
|
||||
require.Equal(t, "WORKS HmEG", items[0].UserMemberships[0].WorksmobileOrgName)
|
||||
require.True(t, items[0].UserMemberships[0].GradeNeedsUpdate)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersDoesNotUpdateWhenWORKSMembershipIsBaronSubset(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
tenantID := "tenant-saman-leaf"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
gpdtdcTenantID := "tenant-is-3"
|
||||
user := domain.User{
|
||||
ID: "user-gpdtdc-grade-with-saman-works-org",
|
||||
Email: "gpdtdc-grade@samaneng.com",
|
||||
Name: "Research Grade User",
|
||||
TenantID: &gpdtdcTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "팀장",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": gpdtdcTenantID,
|
||||
"grade": "책임",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-gpdtdc-grade",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
LevelName: "팀장",
|
||||
PrimaryOrgUnitID: "externalKey:" + tenantID,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + tenantID, Primary: true}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
tenantID: {ID: tenantID, Slug: "saman-leaf", Name: "삼안 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.NotContains(t, items[0].UpdateReasons, "grade")
|
||||
require.NotContains(t, items[0].UpdateReasons, "organization")
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersDoesNotCompareGPDTDCGradeAgainstSamanOrgChartLevel(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
samanID := "045e0b22-fae7-4229-1724-039c5af16849"
|
||||
samanOrgChartID := "97a7e34d-2042-4793-27dc-03ffd68db801"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
infraBIM1ID := "432b5261-421b-4e5f-914f-32d7d22fd01f"
|
||||
user := domain.User{
|
||||
ID: "abaf0788-2d68-4b7d-b40a-c0251f38ae21",
|
||||
Email: "hwan@samaneng.com",
|
||||
Name: "안효원",
|
||||
TenantID: &infraBIM1ID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": samanOrgChartID,
|
||||
"grade": "선임연구원",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": infraBIM1ID,
|
||||
"isPrimary": true,
|
||||
"grade": "선임연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
remoteManager := false
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "045e0b22-fae7-4229-1724-039c5af16849",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 300285955,
|
||||
PrimaryOrgUnitID: "externalKey:" + samanOrgChartID,
|
||||
PrimaryOrgUnitName: "삼안기술개발센터(조직도용)",
|
||||
PrimaryOrgUnitIsManager: &remoteManager,
|
||||
PrimaryOrgUnitPositionName: "조직장 아님",
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 300285955,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{
|
||||
OrgUnitID: "externalKey:" + samanOrgChartID,
|
||||
Primary: true,
|
||||
IsManager: &remoteManager,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
samanID: {ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
samanOrgChartID: {ID: samanOrgChartID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &samanID},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
infraBIM1ID: {ID: infraBIM1ID, Slug: "infra-bim1", Name: "인프라 BIM1", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.NotContains(t, items[0].UpdateReasons, "grade")
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersUsesPrimaryAppointmentForGPDTDCGrade(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
orgChartTenantID := "tenant-rnd-saman"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
gpdtdcTenantID := "tenant-is-3"
|
||||
user := domain.User{
|
||||
ID: "user-orgchart-grade",
|
||||
Email: "orgchart-grade@samaneng.com",
|
||||
Name: "Orgchart Grade User",
|
||||
TenantID: &gpdtdcTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": orgChartTenantID,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": gpdtdcTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "수석연구원",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-orgchart-grade",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
LevelName: "수석 연구원",
|
||||
PrimaryOrgUnitID: "externalKey:" + gpdtdcTenantID,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: false,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + orgChartTenantID, Primary: true}},
|
||||
},
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
orgChartTenantID: {ID: orgChartTenantID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.NotContains(t, items[0].UpdateReasons, "grade")
|
||||
require.Equal(t, "수석 연구원", items[0].BaronGrade)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksConcurrentTenantGradeChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
primaryTenantID := "tenant-saman-leaf"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
gpdtdcTenantID := "tenant-is-3"
|
||||
user := domain.User{
|
||||
ID: "user-concurrent-grade",
|
||||
Email: "concurrent-grade@samaneng.com",
|
||||
Name: "Concurrent Grade User",
|
||||
TenantID: &primaryTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": primaryTenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "팀장",
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": gpdtdcTenantID,
|
||||
"grade": "책임",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-concurrent-grade",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
LevelName: "팀장",
|
||||
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
LevelName: "팀장",
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + primaryTenantID, Primary: true}},
|
||||
},
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: false,
|
||||
LevelName: "선임",
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
primaryTenantID: {ID: primaryTenantID, Slug: "saman-leaf", Name: "삼안 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
|
||||
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
require.Contains(t, items[0].UpdateReasons, "grade")
|
||||
require.NotContains(t, items[0].UpdateReasons, "organization")
|
||||
require.Equal(t, "책임 연구원", items[0].BaronGrade)
|
||||
require.Equal(t, "선임", items[0].WorksmobileLevelName)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-saman"
|
||||
@@ -2307,6 +2702,134 @@ func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *
|
||||
require.Equal(t, "needs_update", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresPrimaryPriorityWhenMembershipsMatch(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
orgChartTenantID := "tenant-rnd-saman"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
gpdtdcTenantID := "tenant-gpdtdc-leaf"
|
||||
user := domain.User{
|
||||
ID: "user-dual-membership",
|
||||
Email: "dual-membership@samaneng.com",
|
||||
Name: "Dual Membership User",
|
||||
TenantID: &gpdtdcTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": orgChartTenantID,
|
||||
"isPrimary": false,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": gpdtdcTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-dual-membership",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
PrimaryOrgUnitID: "externalKey:" + orgChartTenantID,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + orgChartTenantID, Primary: true}},
|
||||
},
|
||||
{
|
||||
DomainID: 1003,
|
||||
Email: user.Email,
|
||||
Primary: false,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
orgChartTenantID: {ID: orgChartTenantID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||
gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.NotContains(t, items[0].UpdateReasons, "organization")
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresBaronMembershipSuperset(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
companyID := "tenant-saman"
|
||||
primaryTenantID := "tenant-primary"
|
||||
secondaryTenantID := "tenant-secondary"
|
||||
user := domain.User{
|
||||
ID: "user-baron-membership-superset",
|
||||
Email: "membership-superset@samaneng.com",
|
||||
Name: "Membership Superset User",
|
||||
TenantID: &primaryTenantID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": primaryTenantID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": secondaryTenantID,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
[]WorksmobileRemoteUser{{
|
||||
ID: "works-user-membership-superset",
|
||||
ExternalID: user.ID,
|
||||
Email: user.Email,
|
||||
DisplayName: user.Name,
|
||||
DomainID: 1001,
|
||||
PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
|
||||
Organizations: []WorksmobileUserOrganization{
|
||||
{
|
||||
DomainID: 1001,
|
||||
Email: user.Email,
|
||||
Primary: true,
|
||||
OrgUnits: []WorksmobileUserOrgUnit{
|
||||
{
|
||||
OrgUnitID: "externalKey:" + primaryTenantID,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
|
||||
primaryTenantID: {ID: primaryTenantID, Slug: "primary", Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
secondaryTenantID: {ID: secondaryTenantID, Slug: "secondary", Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &companyID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
require.NotContains(t, items[0].UpdateReasons, "organization")
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
@@ -2357,6 +2880,32 @@ func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestWorksmobileUsersFromIdentityMirrorIncludesAdditionalAppointmentMembership(t *testing.T) {
|
||||
tenantID := "tenant-gpdtdc-leaf"
|
||||
identity := KratosIdentity{
|
||||
ID: "64d4a839-ee04-4c47-b7b3-4ac6428c56b1",
|
||||
Traits: map[string]any{
|
||||
"email": "researcher@samaneng.com",
|
||||
"name": "Researcher User",
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": tenantID,
|
||||
"isPrimary": true,
|
||||
"grade": "책임",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
users := worksmobileUsersFromIdentityMirror([]KratosIdentity{identity}, []string{tenantID})
|
||||
|
||||
require.Len(t, users, 1)
|
||||
require.Equal(t, identity.ID, users[0].ID)
|
||||
require.NotNil(t, users[0].TenantID)
|
||||
require.Equal(t, tenantID, *users[0].TenantID)
|
||||
require.Equal(t, "책임", worksmobileUserGrade(users[0]))
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "tenant-root"
|
||||
@@ -2517,6 +3066,54 @@ func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.
|
||||
require.Equal(t, "matched", items[0].Status)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersDisplaysPrimaryAppointmentAsBaronPrimaryOrg(t *testing.T) {
|
||||
t.Setenv("HANMAC_DOMAIN_ID", "1002")
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
hanmacCompanyID := "tenant-hanmac"
|
||||
hanmacOrgID := "tenant-hanmac-org"
|
||||
gpdtdcID := "tenant-gpdtdc"
|
||||
gsimID := "tenant-gsim-dev"
|
||||
user := domain.User{
|
||||
ID: "user-gsim-primary",
|
||||
Email: "gsim-primary@hanmaceng.co.kr",
|
||||
Name: "GSIM Primary User",
|
||||
TenantID: &hanmacOrgID,
|
||||
Status: domain.UserStatusActive,
|
||||
Metadata: domain.JSONMap{
|
||||
"additionalAppointments": []any{
|
||||
map[string]any{
|
||||
"tenantId": hanmacOrgID,
|
||||
"isPrimary": false,
|
||||
},
|
||||
map[string]any{
|
||||
"tenantId": gsimID,
|
||||
"isPrimary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := compareWorksmobileUsers(
|
||||
[]domain.User{user},
|
||||
nil,
|
||||
true,
|
||||
map[string]domain.Tenant{
|
||||
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
|
||||
hanmacCompanyID: {ID: hanmacCompanyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
|
||||
hanmacOrgID: {ID: hanmacOrgID, Slug: "rnd-hanmac", Name: "한맥기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &hanmacCompanyID},
|
||||
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID},
|
||||
gsimID: {ID: gsimID, Slug: "gsim-dev", Name: "GSIM개발", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, "missing_in_worksmobile", items[0].Status)
|
||||
require.Equal(t, gsimID, items[0].BaronPrimaryOrgID)
|
||||
require.Equal(t, "gsim-dev", items[0].BaronPrimaryOrgSlug)
|
||||
require.Equal(t, "GSIM개발", items[0].BaronPrimaryOrgName)
|
||||
}
|
||||
|
||||
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
|
||||
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
|
||||
rootID := "tenant-root"
|
||||
|
||||
Reference in New Issue
Block a user