Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict
# local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요.
# REFRESH_TOKEN_COOKIE_SECURE=true

# 선택 사항: local(H2)에서 로그인 가능한 user_account 더미 계정 20명을 생성합니다.
# 설정한 ADMIN 1명과 고정된 가상 계정 19명이 같은 로컬 비밀번호를 사용합니다.
# 선택 사항: local(H2) 또는 로컬 PostgreSQL(dev)에 회사 2개, 계정 23개, 근로자 10명을 생성합니다.
# Demo Company에는 기존 계정 20개와 근로자 5명, Task 5개, 근로자 서류 7개,
# Task 최근 활동 3개가 생성됩니다. Test Company에는 역할별 계정 3개와 근로자 5명이 생성됩니다.
# 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다.
# PostgreSQL dev/prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다.
# prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다.
# 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다.
DEMO_SEED_ENABLED=false
# DEMO_SEED_ADMIN_PASSWORD=
# DEMO_SEED_ADMIN_DISPLAY_NAME=데모 관리자
# DEMO_SEED_ADMIN_EMAIL=demo.admin@example.com
# DEMO_SEED_COMPANY_NAME=FOWOCO Demo Company
# DEMO_SEED_TEST_COMPANY_NAME=FOWOCO Test Company
33 changes: 23 additions & 10 deletions docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,27 +151,40 @@ export SPRING_PROFILES_ACTIVE=dev
runtime 계정은 업무 DML, migration 계정은 Flyway 적용만 담당합니다. 실제
비밀번호·API Key·Token은 Git, Issue, Discussion과 로그에 올리지 않습니다.

## 선택 사항: local Demo Seed
## 선택 사항: Demo Seed

local H2에서만 사용할 사업장과 `user_account` 더미 계정 20명이 필요할 때
명시적으로 켭니다. 설정한 관리자 1명과 고정된 가상 계정 19명
(`ADMIN` 1명, `HR` 12명, `VIEWER` 6명)을 만들며, 모든 계정은 로컬에서 설정한
같은 비밀번호로 로그인할 수 있습니다. 기본값과 비밀번호 기본값은 없습니다.
local H2 또는 로컬 PostgreSQL `dev`에서만 사용할 데모 데이터가 필요할 때

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재성님 데이터 만들어주신거 감사합니다. 추가로 만들어 주셨으면 하는 데이터가 있어서 리뷰 남깁니다

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클라이언트 화면 검증 관점에서 보면 Task/Document/AuditEvent 시드가 없어서, 이 PR만으로는 대시보드·업무함·문서함·근로자 상세 화면이 전부 빈 상태로만 보이게 되어서 화면 확인이 어려워요. 화면 확인을 위해 아래 데이터도 함께 시드해주실 수 있을까요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Task (업무) 시드 — Demo Company 근로자 기준으로 상태별 최소 1건씩 필요해요.
  • DRAFT (AI 준비 완료 카드 확인용)
  • READY_FOR_REVIEW (승인 대기 카드/탭 확인용)
  • WAITING_WORKER / WAITING_EXTERNAL (근로자 응답 대기 카드 확인용)
  • COMPLETED — updated_at을 오늘 날짜로 최소 1건 (업무함 "오늘 완료" 지표 확인용)
  • 기한(due_date)도 7일 이내/30일 이내/여유 있는 케이스를 섞어주시면 긴급도 UI까지 확인 가능할거같아요

  1. Document (서류) 시드 — 근로자당 1~2건, 상태 다양하게 필요해요
  • MISSING / SUBMITTED / VERIFIED 각각 최소 1건
  • expiry_date를 30일 이내로 잡은 문서 1건 이상 (문서함 "만료 예정" 탭/지표 확인용)

  1. AuditEvent (활동 이력) 시드 — 위에서 만든 Task 중 최소 1건에 대해 TASK_CREATED/APPROVAL_REQUESTED 같은 이벤트 2~3건 (근로자 상세 패널의 "최근 Timeline" 확인용)

데이터 모델·API 변경 없이 시드 데이터만 추가하는 선에서 가능하면, 이번 PR 범위에 포함하거나 후속 PR로 분리해주셔도 좋을 것 같습니다.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hywznn teams에 적었던 내용인데 참고해서 현준님도 리뷰 부탁드립니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BcKmini 위를 구분짓게 해주기 위해서 문서에 있는 slot에서 필요한 데이터들을 최대한 다 넣어달라고 했는데 + 라벨값들도 같이 넣어달라고 하겠습니다

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 참고해주세요

화면 참고 PR 필요한 데이터
Today(대시보드) fowoco/client#23, #202 Task 상태별(READY_FOR_REVIEW/DRAFT/COMPLETED), 기한 다양
업무함 fowoco/client#26, #203 Task 상태·기한 전체 조합 (Metric Strip 4종)
업무 상세(CASE) fowoco/client#33 Task 1건 + AuditEvent 여러 건(상태 전이 이력)
근로자 목록/상세 fowoco/client#35, #65, #204 Task(worker_id 연결) + AuditEvent(Timeline)
서류관리 fowoco/client#66, #205 Document 상태별(MISSING/SUBMITTED/VERIFIED) + 만료 임박 건
기한 색상 구분 fowoco/client#87 Task/Document due_date·expiry_date 다양한 값

명시적으로 켭니다. 모든 계정은 로컬에서 설정한 같은 비밀번호로 로그인할 수
있으며, 기본 비밀번호는 없습니다.

| 회사 | 계정 | 근로자 |
| --- | --- | --- |
| `FOWOCO Demo Company` | 기존 20명 (`ADMIN` 2, `HR` 12, `VIEWER` 6) | 5명 |
| `FOWOCO Test Company` | 3명 (`ADMIN` 1, `HR` 1, `VIEWER` 1) | 5명 |

Demo Company에는 화면 검증용 업무 데이터도 함께 생성됩니다.

- Task 5개: `DRAFT`(AI 후보), `READY_FOR_REVIEW`, `WAITING_WORKER`,
`WAITING_EXTERNAL`, `COMPLETED` 각 1개
- 마감일: 7일 이내, 30일 이내, 30일 초과 구간을 모두 포함
- 근로자 서류 7개: 근로자별 1~2개, `MISSING`, `SUBMITTED`, `VERIFIED` 포함
- 30일 이내 만료 서류 1개 이상
- 승인 대기 Task의 최근 AuditEvent 3개

```bash
export DEMO_SEED_ENABLED=true
export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값'
./gradlew bootRun
```

같은 설정으로 재실행해도 중복 생성하지 않습니다. 같은 이메일이 다른 사업장이나
역할로 존재하면 덮어쓰지 않고 시작을 중단합니다. 이메일은 모두
`demo.*@example.com` 형식의 가상 주소이며 실제 개인정보를 포함하지 않습니다.
같은 설정으로 재실행해도 중복 생성하지 않습니다. 고정 ID나 이메일이 다른
사업장·역할·근로자 정보와 충돌하면 덮어쓰지 않고 시작을 중단합니다. 이메일과
근로자 이름은 모두 데모 전용 값이며 실제 개인정보를 포함하지 않습니다.
최초 계정을 확인한 후에는
`DEMO_SEED_ENABLED=false`로 돌려놓습니다.

PostgreSQL `dev`·`prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기
계정을 준비합니다.
공유 `dev``prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기 계정을
준비합니다.

### 팀 공통 PostgreSQL Demo 실행

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public record DemoAuthSeedProperties(
boolean enabled,
UUID companyId,
String companyName,
UUID testCompanyId,
String testCompanyName,
UUID adminUserId,
String adminDisplayName,
String adminEmail,
Expand All @@ -19,6 +21,8 @@ public String toString() {
return "DemoAuthSeedProperties[enabled=" + enabled
+ ", companyId=" + companyId
+ ", companyName=" + companyName
+ ", testCompanyId=" + testCompanyId
+ ", testCompanyName=" + testCompanyName
+ ", adminUserId=" + adminUserId
+ ", adminDisplayName=" + adminDisplayName
+ ", adminEmail=" + adminEmail
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;

@Order(0)
class DemoAuthSeedRunner implements ApplicationRunner {

private static final Logger LOGGER = LoggerFactory.getLogger(DemoAuthSeedRunner.class);
Expand All @@ -43,6 +45,11 @@ class DemoAuthSeedRunner implements ApplicationRunner {
demoUser("90000000-0000-0000-0000-000000000020", "데모 조회자 05", "demo.viewer05@example.com", UserRole.VIEWER),
demoUser("90000000-0000-0000-0000-000000000021", "데모 조회자 06", "demo.viewer06@example.com", UserRole.VIEWER)
);
private static final List<DemoUser> TEST_USERS = List.of(
demoUser("91000000-0000-0000-0000-000000000002", "테스트 관리자", "test.admin@example.com", UserRole.ADMIN),
demoUser("91000000-0000-0000-0000-000000000003", "테스트 HR", "test.hr@example.com", UserRole.HR),
demoUser("91000000-0000-0000-0000-000000000004", "테스트 조회자", "test.viewer@example.com", UserRole.VIEWER)
);

private final DemoAuthSeedProperties properties;
private final CompanyRepository companyRepository;
Expand Down Expand Up @@ -75,26 +82,30 @@ class DemoAuthSeedRunner implements ApplicationRunner {
public void run(ApplicationArguments arguments) {
validateConfiguration();
Instant now = clock.instant();
ensureCompany(now);
ensureCompany(properties.companyId(), properties.companyName(), now);
ensureCompany(properties.testCompanyId(), properties.testCompanyName(), now);

seedUser(new DemoUser(
seedUser(properties.companyId(), new DemoUser(
properties.adminUserId(),
properties.adminDisplayName(),
properties.adminEmail(),
UserRole.ADMIN
), now);
DEMO_USERS.forEach(user -> seedUser(user, now));
DEMO_USERS.forEach(user -> seedUser(properties.companyId(), user, now));
TEST_USERS.forEach(user -> seedUser(properties.testCompanyId(), user, now));
LOGGER.info(
"demo_auth_seed ready company_id={} user_count={}",
properties.companyId(),
DEMO_USERS.size() + 1
"demo_auth_seed ready company_count={} user_count={}",
2,
DEMO_USERS.size() + TEST_USERS.size() + 1
);
}

private void validateConfiguration() {
Objects.requireNonNull(properties.companyId(), "demo seed companyId must not be null");
Objects.requireNonNull(properties.testCompanyId(), "demo seed testCompanyId must not be null");
Objects.requireNonNull(properties.adminUserId(), "demo seed adminUserId must not be null");
requireText(properties.companyName(), "demo seed companyName");
requireText(properties.testCompanyName(), "demo seed testCompanyName");
requireText(properties.adminDisplayName(), "demo seed adminDisplayName");
requireText(properties.adminEmail(), "demo seed adminEmail");
String password = requireText(properties.adminPassword(), "DEMO_SEED_ADMIN_PASSWORD");
Expand All @@ -105,22 +116,25 @@ private void validateConfiguration() {
+ " characters"
);
}
if (DEMO_USERS.stream().anyMatch(user -> user.userId().equals(properties.adminUserId()))) {
if (properties.companyId().equals(properties.testCompanyId())) {
throw new IllegalStateException("demo seed company ids must be different");
}
if (reservedUsers().stream().anyMatch(user -> user.userId().equals(properties.adminUserId()))) {
throw new IllegalStateException("demo seed adminUserId conflicts with a reserved demo user id");
}
String normalizedAdminEmail = UserAccount.normalizeEmail(properties.adminEmail());
if (DEMO_USERS.stream().anyMatch(user ->
if (reservedUsers().stream().anyMatch(user ->
UserAccount.normalizeEmail(user.email()).equals(normalizedAdminEmail))) {
throw new IllegalStateException("demo seed adminEmail conflicts with a reserved demo user email");
}
}

private void ensureCompany(Instant now) {
Optional<Company> existingCompany = companyRepository.findById(properties.companyId());
private void ensureCompany(UUID companyId, String companyName, Instant now) {
Optional<Company> existingCompany = companyRepository.findById(companyId);
if (existingCompany.isEmpty()) {
companyRepository.insert(Company.create(
properties.companyId(),
properties.companyName(),
companyId,
companyName,
now
));
return;
Expand All @@ -130,22 +144,22 @@ private void ensureCompany(Instant now) {
}
}

private void seedUser(DemoUser demoUser, Instant now) {
private void seedUser(UUID companyId, DemoUser demoUser, Instant now) {
String normalizedEmail = UserAccount.normalizeEmail(demoUser.email());
Optional<UserAccount> existingByEmail =
userAccountRepository.findByNormalizedEmail(normalizedEmail);
if (existingByEmail.isPresent()) {
verifyExistingUser(existingByEmail.orElseThrow(), demoUser);
verifyExistingUser(existingByEmail.orElseThrow(), companyId, demoUser);
return;
}
if (userAccountRepository
.findByUserIdAndCompanyId(demoUser.userId(), properties.companyId())
.findByUserIdAndCompanyId(demoUser.userId(), companyId)
.isPresent()) {
throw new IllegalStateException("a reserved demo user id already belongs to another email");
}
userAccountRepository.insert(UserAccount.create(
demoUser.userId(),
properties.companyId(),
companyId,
demoUser.displayName(),
demoUser.email(),
passwordEncoder.encode(properties.adminPassword()),
Expand All @@ -154,9 +168,9 @@ private void seedUser(DemoUser demoUser, Instant now) {
));
}

private void verifyExistingUser(UserAccount userAccount, DemoUser demoUser) {
private void verifyExistingUser(UserAccount userAccount, UUID companyId, DemoUser demoUser) {
if (!demoUser.userId().equals(userAccount.userId())
|| !properties.companyId().equals(userAccount.companyId())
|| !companyId.equals(userAccount.companyId())
|| userAccount.role() != demoUser.role()
|| !userAccount.canLogin()) {
throw new IllegalStateException(
Expand All @@ -176,6 +190,10 @@ private static DemoUser demoUser(String userId, String displayName, String email
return new DemoUser(UUID.fromString(userId), displayName, email, role);
}

private static List<DemoUser> reservedUsers() {
return java.util.stream.Stream.concat(DEMO_USERS.stream(), TEST_USERS.stream()).toList();
}

private record DemoUser(UUID userId, String displayName, String email, UserRole role) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.fowoco.server.demo.infrastructure.seed;

import com.fowoco.server.audit.application.port.AuditEventRepository;
import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties;
import com.fowoco.server.task.application.TaskContentCodec;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.worker.application.port.WorkerDocumentRepository;
import java.time.Clock;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class DemoOperationalSeedConfiguration {

@Bean
@ConditionalOnProperty(prefix = "app.demo-seed", name = "enabled", havingValue = "true")
DemoOperationalSeedRunner demoOperationalSeedRunner(
DemoAuthSeedProperties properties,
TaskRepository taskRepository,
TaskContentCodec taskContentCodec,
WorkerDocumentRepository workerDocumentRepository,
AuditEventRepository auditEventRepository,
Clock clock
) {
return new DemoOperationalSeedRunner(
properties,
taskRepository,
taskContentCodec,
workerDocumentRepository,
auditEventRepository,
clock
);
}
}
Loading
Loading