feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import axios from 'axios'
import { onboardingService } from '../onboarding.service'
import type { OnboardingRequest, OnboardingResponse, EmployeeStatusResponse } from '../../types/onboarding'
// Mock axios
vi.mock('axios', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}))
const mockedAxios = axios as unknown as {
post: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
}
describe('OnboardingService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('onboardEmployee', () => {
it('should successfully onboard an employee', async () => {
// Arrange
const request: OnboardingRequest = {
resume_id: 1,
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
keycloak_username: 'wang.ming',
hire_date: '2026-02-21',
departments: [
{
department_id: 9,
position: '資深工程師',
membership_type: 'permanent',
},
],
role_ids: [1, 2],
storage_quota_gb: 20,
email_quota_mb: 5120,
}
const mockResponse: OnboardingResponse = {
message: 'Employee onboarded successfully',
employee: {
tenant_id: 1,
seq_no: 1,
tenant_emp_code: 'PWD0001',
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
keycloak_username: 'wang.ming',
name: '王明',
hire_date: '2026-02-21',
},
summary: {
departments_assigned: 1,
roles_assigned: 2,
services_enabled: 5,
},
}
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await onboardingService.onboardEmployee(request)
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://localhost:10181/api/v1/emp-lifecycle/onboard',
request
)
expect(result).toEqual(mockResponse)
})
it('should throw error when API fails', async () => {
// Arrange
const request: OnboardingRequest = {
resume_id: 999,
keycloak_user_id: 'invalid-uuid',
keycloak_username: 'test',
hire_date: '2026-02-21',
departments: [],
role_ids: [],
}
const errorMessage = 'Resume ID 999 not found'
mockedAxios.post.mockRejectedValueOnce({
response: {
data: { detail: errorMessage },
status: 404,
},
})
// Act & Assert
await expect(
onboardingService.onboardEmployee(request)
).rejects.toThrow()
})
})
describe('getEmployeeStatus', () => {
it('should fetch employee status successfully', async () => {
// Arrange
const tenantId = 1
const seqNo = 1
const mockResponse: EmployeeStatusResponse = {
employee: {
tenant_id: 1,
seq_no: 1,
tenant_emp_code: 'PWD0001',
name: '王明',
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
keycloak_username: 'wang.ming',
hire_date: '2026-02-21',
resign_date: null,
employment_status: 'active',
storage_quota_gb: 20,
email_quota_mb: 5120,
},
departments: [
{
department_id: 9,
department_name: '玄鐵風能',
position: '資深工程師',
membership_type: 'permanent',
joined_at: '2026-02-21T10:00:00',
},
],
roles: [
{
role_id: 1,
role_name: 'HR管理員',
role_code: 'HR_ADMIN',
assigned_at: '2026-02-21T10:00:00',
},
],
services: [
{
service_id: 1,
service_name: '單一簽入',
service_code: 'SSO',
quota_gb: null,
quota_mb: null,
enabled_at: '2026-02-21T10:00:00',
},
],
}
mockedAxios.get.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await onboardingService.getEmployeeStatus(tenantId, seqNo)
// Assert
expect(mockedAxios.get).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/emp-lifecycle/${tenantId}/${seqNo}/status`
)
expect(result).toEqual(mockResponse)
})
it('should throw error when employee not found', async () => {
// Arrange
const tenantId = 1
const seqNo = 999
mockedAxios.get.mockRejectedValueOnce({
response: {
data: { detail: 'Employee not found' },
status: 404,
},
})
// Act & Assert
await expect(
onboardingService.getEmployeeStatus(tenantId, seqNo)
).rejects.toThrow()
})
})
describe('offboardEmployee', () => {
it('should successfully offboard an employee', async () => {
// Arrange
const tenantId = 1
const seqNo = 1
const mockResponse = {
message: 'Employee offboarded successfully',
employee: {
tenant_emp_code: 'PWD0001',
resign_date: '2026-02-21',
},
summary: {
departments_removed: 1,
roles_revoked: 2,
services_disabled: 5,
},
}
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await onboardingService.offboardEmployee(tenantId, seqNo)
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/emp-lifecycle/${tenantId}/${seqNo}/offboard`
)
expect(result).toEqual(mockResponse)
})
})
})

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import axios from 'axios'
import { tenantService } from '../tenant.service'
import type { Tenant, TenantUpdateRequest, TenantUpdateResponse } from '../../types/tenant'
// Mock axios
vi.mock('axios', () => ({
default: {
get: vi.fn(),
put: vi.fn(),
},
}))
const mockedAxios = axios as unknown as {
get: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
}
describe('TenantService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getTenant', () => {
it('should fetch tenant information successfully', async () => {
// Arrange
const tenantId = 1
const mockTenant: Tenant = {
id: 1,
code: 'PWD',
name: 'Porsche World',
name_eng: 'Porsche World Co., Ltd.',
tax_id: '12345678',
prefix: 'PWD',
tel: '02-1234-5678',
add: '台北市信義區...',
url: 'https://porscheworld.tw',
plan_id: 'starter',
max_users: 50,
storage_quota_gb: 1000,
status: 'active',
is_sysmana: false,
is_active: true,
created_at: '2026-01-01T00:00:00',
updated_at: '2026-02-21T00:00:00',
}
mockedAxios.get.mockResolvedValueOnce({ data: mockTenant })
// Act
const result = await tenantService.getTenant(tenantId)
// Assert
expect(mockedAxios.get).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/tenants/${tenantId}`
)
expect(result).toEqual(mockTenant)
})
it('should throw error when tenant not found', async () => {
// Arrange
const tenantId = 999
mockedAxios.get.mockRejectedValueOnce({
response: {
data: { detail: 'Tenant not found' },
status: 404,
},
})
// Act & Assert
await expect(tenantService.getTenant(tenantId)).rejects.toThrow()
})
})
describe('updateTenant', () => {
it('should update tenant information successfully', async () => {
// Arrange
const tenantId = 1
const updateData: TenantUpdateRequest = {
name: 'Porsche World Updated',
tax_id: '87654321',
tel: '02-8765-4321',
add: '台北市大安區...',
url: 'https://newporscheworld.tw',
}
const mockResponse: TenantUpdateResponse = {
message: 'Tenant updated successfully',
tenant: {
id: 1,
code: 'PWD',
name: 'Porsche World Updated',
name_eng: 'Porsche World Co., Ltd.',
tax_id: '87654321',
prefix: 'PWD',
tel: '02-8765-4321',
add: '台北市大安區...',
url: 'https://newporscheworld.tw',
plan_id: 'starter',
max_users: 50,
storage_quota_gb: 1000,
status: 'active',
is_sysmana: false,
is_active: true,
created_at: '2026-01-01T00:00:00',
updated_at: '2026-02-21T10:00:00',
},
}
mockedAxios.put.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await tenantService.updateTenant(tenantId, updateData)
// Assert
expect(mockedAxios.put).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/tenants/${tenantId}`,
updateData
)
expect(result).toEqual(mockResponse)
})
it('should throw error when update fails', async () => {
// Arrange
const tenantId = 1
const updateData: TenantUpdateRequest = {
name: 'Test',
}
mockedAxios.put.mockRejectedValueOnce({
response: {
data: { detail: 'Update failed' },
status: 400,
},
})
// Act & Assert
await expect(tenantService.updateTenant(tenantId, updateData)).rejects.toThrow()
})
})
})