1
0
forked from baron/baron-sso

chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)},

View File

@@ -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 {

View File

@@ -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",

View File

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

View File

@@ -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 {

View File

@@ -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"