Skip to content

release: v0.1.0 (Week 2)#35

Merged
peterabcd merged 16 commits into
mainfrom
dev
Jun 7, 2026
Merged

release: v0.1.0 (Week 2)#35
peterabcd merged 16 commits into
mainfrom
dev

Conversation

@peterabcd

Copy link
Copy Markdown
Collaborator

v0.1.0 — Week 2 종료 마크

이 PR 은 dev 의 현재 상태를 main 으로 첫 머지하는 건. 이후 main 은 공식 release용, dev 는 이어서 쿔 staging 탘.

포함 내용

Week 2 의 12 PR 전부:

PR 요약
#23 Next.js 15 + Tailwind + shadcn + Docker/Caddy 스캐폴드
#24 Prisma 6 + SQLite + 초기 migration
#25 Auth.js v5 Google OAuth + @snu.ac.kr 화이트리스트 + /admin/members
#26 세션 생성 + 상세 + 참여자 행 (페이스 자동 계산)
#27 단체사진 업로드 (로컬 볼륨)
#28 Hotfix: 빈 입력 noop / encType 제거 / Session.id cuid→Int
#29 폼 inline validation + double-submit 차단
#30 /sessions 아카이브 + 워크플로우 SKILL 업데이트
#31 사진/admin 폼 retrofit + CI + Deploy + 회고
#32 Hotfix: Docker image build, GHCR 이름, deploy secrets 분기
#33 Hotfix: Prisma runtime deps + /api/health + N100 git sync
#34 audit: cursor 페이지네이션, 죽은 env, server-only 가드

검증 상태

이 머지 이후

  • 이 PR squash/merge 후 v0.1.0 태그 추가.
  • main 으로의 다음 머지는 Week 3 끝을 예정 (v0.2.0).

peterabcd and others added 16 commits May 31, 2026 21:13
…ding

- README: project overview, branch strategy, doc index
- docs/wiki/: 6 wiki pages (Home, plan, tech-stack, screen-flow, agent-workflow, prompt-patterns, checkpoints) + Week1 retrospective
- .github/: issue templates (feat/chore/bug) + PR template with checkpoint checklist
- .gjc/skills/murun-feature: agent skill stub for feature workflow
- scripts/bootstrap-issues.sh: gh-based label + 15-issue bootstrap
- .gitattributes: enforce LF
- .gitignore: node/next/prisma/vercel/runtime state
GitHub Wiki page URLs are /wiki/<Page-Name> without .md.
Links like ./01-Project-Plan.md broke navigation between wiki pages.

- Home.md: 8 internal links → no extension
- 04-Agent-Workflow.md: internal Checkpoints link → no extension
- 04-Agent-Workflow.md: external .gjc/skills link → absolute repo URL
  (wiki repo is separate from main repo; relative ../../ doesn't resolve)
…odel

Stack pivot:
- Vercel + Supabase → N100 self-host + Docker Compose + Caddy + SQLite
- Magic link → Auth.js v5 Google OAuth (hd=snu.ac.kr) + User.approved whitelist
- Storage SaaS → local volume, preserve photo originals, next/image cache

Feature model pivot:
- 'Operator batch-inputs everyone' → host creates session page,
  each participant adds their own row (post-comment shape)
- /admin/members approval flow
- Search filter gets participant-count

Doc updates:
- 01-Project-Plan: user model, MVP scope, risks rewritten
- 02-Tech-Stack: rewritten end-to-end with Caddy/Docker/SQLite rationale
- 03-Screen-Flow: host/participant flows + Prisma schema + permission matrix
- 06-Checkpoints: rewritten as 7 'AI interpretation fidelity' checks,
  moved machine-checkable items out to CI/PR template
- Retrospective-Week1: rewritten in first-person, focused on stack-decision
  cascade and planning-model pivot
- README: brand to 뮤런, model summary updated
- 04 / 05 / SKILL.md: removed SaaS references, added new guards/constraints

Issue updates (docs/initial-issues.md + scripts/update-issues.sh):
- CLOSE #3 (Vercel), #4 (Supabase project), #8 (magic link), #12 (Supabase Storage)
- EDIT #7, #9, #10, #14 to reflect new model
- CREATE 7 new issues: N100 bootstrap, .env.example, Google OAuth,
  whitelist admin page, participation row CRUD, photo upload (local),
  SSH deploy pipeline
- DEPRECATE scripts/bootstrap-issues.sh with hard exit

Brand: 무런 → 뮤런 throughout.
…ap (#23)

Closes #6, N-1, N-2.

App scaffold:
- Next.js 15.5 (App Router, react 19, ts strict, output:standalone behind env)
- Tailwind 3.4 + shadcn-style Button + lib/utils cn() + components.json
- ESLint flat config extending next/core-web-vitals + next/typescript
- Empty / page renders '뮤런' + Button. No domain pages yet.
- pnpm 11.5.1, lockfile committed; sharp/unrs-resolver build policy set
  in pnpm-workspace.yaml (deferred to user via approve-builds)

Deploy:
- deploy/Dockerfile: multi-stage, alpine, non-root, standalone output via
  NEXT_OUTPUT_STANDALONE=true ENV (avoids Windows symlink EPERM locally)
- deploy/docker-compose.yml: caddy + app, volumes for data + uploads,
  healthcheck on app, ghcr.io image tag
- deploy/Caddyfile: {$DOMAIN} interpolation, security headers, _next/static
  long cache, reverse_proxy to app:3000
- deploy/README.md: host-side run/update/rollback/troubleshoot/duckdns notes
- .dockerignore covers node_modules/.next/.env*

Env / repo polish:
- .env.example with placeholders for app, DB, Auth.js, OAuth, uploads
  (commented sections for next PRs)
- .gitignore: deploy/.env*, data/, uploads/, tmp/
- README quick-start rewritten for local dev + deploy

Verified locally:
- pnpm typecheck: pass
- pnpm lint: pass
- pnpm build: pass (4 routes, /, /_not-found prerendered static)
- pnpm dev: GET / -> 200 with '뮤런' title + body

Spec: .gjc/specs/scaffold-bootstrap.md
Closes #7.

Schema (per docs/wiki/03-Screen-Flow.md §4):
- User: googleSub/email unique, approved + approvedBy self-relation,
  role as String (SQLite has no enum; zod-checked at lib level),
  joinedAt, hostedSessions, participations
- Session: hostId FK, date(indexed), startTime, location, weather,
  groupPhotoPath, notes, createdAt/updatedAt
- Participation: (sessionId, userId) unique, distanceKm/durationSec
  nullable, onDelete: Cascade from Session
- generator client: binaryTargets includes linux-musl-openssl-3.0.x
  for alpine runtime

Init migration generated and committed: prisma/migrations/20260603103804_init/

Tooling:
- prisma 6.19.3 (downgraded from 7.x — Prisma 7 dropped datasource.url
  in schema.prisma in favor of driver adapters, too much churn for week 2)
- @prisma/client 6.19.3
- pnpm-workspace.yaml: nodeLinker=hoisted (Prisma client postinstall
  path assumes flat node_modules; isolated layout breaks 'did not
  initialize yet' import)
- onlyBuiltDependencies/allowBuilds updated for prisma, @prisma/engines,
  sharp (flipped to true for future next/image use)
- package.json scripts: db:generate, db:migrate, db:studio, db:push,
  db:reset, postinstall=prisma generate

lib/db.ts:
- PrismaClient singleton via globalThis cache (HMR-safe in dev)
- Query logging in dev, error-only in prod

next.config.ts:
- outputFileTracingIncludes for .prisma/client and @prisma/client
- serverExternalPackages for @prisma/client (don't bundle)

Docker:
- Add openssl + libc6-compat to all stages (Prisma engine deps on alpine)
- builder: run 'prisma generate' before 'next build'
- runner: copy prisma/, node_modules/prisma, @prisma, .prisma
- deploy/entrypoint.sh: 'prisma migrate deploy' then 'node server.js'
  (idempotent migration on every container start)
- deploy/.env.example: DATABASE_URL=file:/app/data/murun.db,
  NODE_ENV=production, UPLOADS_DIR

Verified locally:
- pnpm install + postinstall (prisma generate) — exit 0
- pnpm db:migrate --name init — migration.sql generated + applied
- pnpm typecheck / lint / build — all pass
- Direct smoke (tmp/smoke.mjs not committed):
  - User.create succeeds, second create with same googleSub returns P2002
  - Session.create + Participation.create roundtrip succeed
  - Duplicate (sessionId, userId) returns P2002
  - Session.delete cascades Participation count to 0

Spec: .gjc/specs/prisma-schema-init.md
…#25)

Closes #18, #19.

Auth.js v5 (beta):
- next-auth@5.0.0-beta + @auth/prisma-adapter
- Edge-safe config split: auth.config.ts (Edge) vs lib/auth.ts (server)
  middleware uses authConfig only (no Prisma/jose in Edge runtime)
- Google provider with authorization params hd=snu.ac.kr (1st filter)
- signIn callback re-verifies email domain server-side (hd is client-bypassable)
- session strategy: jwt (avoids name collision with our domain Session
  model + skips DB session table)
- jwt callback re-reads approved/role from DB on every request so admin
  approval reflects immediately (no re-login needed)
- session callback exposes user.id/approved/role on Session object
- types/next-auth.d.ts augments Session/JWT typing

Schema (migration 2: auth_models):
- User: removed googleSub (Account.providerAccountId replaces),
  renamed avatarUrl→image, name→non-nullable, added emailVerified
  (Auth.js Prisma Adapter standard shape)
- Account: standard Auth.js fields (provider, providerAccountId,
  tokens, etc.) with unique(provider, providerAccountId) +
  onDelete:Cascade from User
- VerificationToken: standard (unused but Adapter requires)

Guard / routing:
- lib/guard.ts: requireUser / requireApproved / requireAdmin helpers
  for Server Components and Server Actions; redirect or notFound on
  failure
- middleware.ts: Edge-runtime 1st-pass gate
  - /login: logged-in users bounce to / or /pending by approved
  - protected routes without session → /login with callbackUrl
  - /admin/* without ADMIN → 404 rewrite (hides existence)

Pages:
- /login: SNU Google button, server action calls signIn('google')
- /pending: shows email + 안내 + logout
- /admin/members: ADMIN only, lists pending + active members,
  approve/reject buttons via server action
- /: redirects by auth state (login→/login, !approved→/pending),
  placeholder content + logout for now

Server actions (app/admin/members/actions.ts):
- approveUser: requireAdmin + zod parse + cannot-approve-self throw
  + User.update + revalidatePath
- rejectUser: requireAdmin + cannot-reject-self throw + User.delete
  (Account cascade-deletes; Session FK would fail but reject is only
  used for unapproved users who haven't created sessions)

Env:
- .env.example + deploy/.env.example: AUTH_URL, AUTH_SECRET,
  AUTH_TRUST_HOST, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, AUTH_GOOGLE_HD

README:
- Quick-start updated: pnpm db:migrate step
- First admin bootstrap: SQL update via prisma studio (1-time)
- Google OAuth client creation steps

Verified locally:
- pnpm install + postinstall (prisma generate)
- pnpm db:migrate --name auth_models (migration 2 applied)
- pnpm typecheck / lint / build — all pass, no Edge runtime warnings
  (after auth.config split)
- pnpm dev smoke (with AUTH_SECRET injected):
  - /login → 200 with '뮤런' + '계속하기' + '승인' text
  - /, /pending, /admin/members (no session) → 307 redirect
  - /api/auth/providers → returns google provider with callbackUrl
- Actual OAuth flow not exercised yet (requires user to provision
  Google Cloud Console credentials)

Spec: .gjc/specs/auth-oauth-whitelist.md
…pace (#26)

Closes #9, #10, #20.

Core vertical slice — '호스트가 페이지 만들고 참여자가 본인 행 적는' 흐름이
한 PR 안에서 end-to-end로 동작한다. 사진/아카이브/세션수정 모두 별 PR로 분리.

Pure helpers (lib/pace.ts):
- calcPaceSecPerKm, formatPace ('M"SS"/km'), formatDurationSec,
  formatDistanceKm, parseDurationInput (분/초 두 input → 초),
  parseDistanceInput, splitDurationSec (prefill용)
- 저장 안 함 — 표시 시점에 계산 (03-Screen-Flow §4 결정 그대로)

UI 컴포넌트 (shadcn 표준 복사):
- components/ui/input.tsx, label.tsx, textarea.tsx
- @radix-ui/react-label 의존 추가

세션 생성 (/sessions/new):
- 폼: 날짜(date, today default) / 시작 시간(time, optional) / 장소(필수) /
  날씨(자유텍스트, optional) / 코스 메모(textarea, optional)
- Server action: requireApproved → zod 검증 → Session.create(hostId=user.id)
  → /sessions/[id] redirect
- 사진 자리는 의도적으로 비움 (다음 PR에서 추가될 것을 페이지 텍스트로 안내)

세션 상세 (/sessions/[id]):
- 헤더: 날짜·요일·장소·시작시간·날씨·호스트 이름
- 사진 영역: 회색 placeholder (다음 PR에서 실제 이미지)
- 참여자 표: 이름·거리·기록·페이스. createdAt asc 정렬. 본인 행에 '(나)' 표시.
  거리·기록 둘 다 있어야 페이스 표시, 한쪽만이면 — 표시.
- 총 거리 합계 헤더에 표시
- '내 기록' 폼 (MyParticipationForm, server component):
  - existing 없으면 빈 폼 + [추가]
  - existing 있으면 prefill + [수정] + [삭제]
- 코스 메모 푸터 (있을 때만)
- requireApproved + notFound 가드

Participation actions (app/sessions/[id]/actions.ts):
- upsertParticipation: requireApproved + sessionId 존재 확인 +
  parseDistance/parseDuration + 모두 빈 값 throw +
  Prisma upsert on (sessionId, userId)  — userId 는 항상 server-side.
- deleteParticipation: requireApproved + deleteMany WHERE sessionId+userId.
  본인 행만 자연스럽게.
- 둘 다 revalidatePath('/sessions/${id}')

홈 (app/page.tsx):
- '새 세션 만들기' CTA. 아카이브 리스트는 placeholder.

Build:
- 7 routes, /sessions/new + /sessions/[id] 추가
- /sessions/[id] First Load JS 114kB (table + form)
- Edge runtime warning 2건 잔존 (jose JWE deflate, 우리는 미사용 — 비치명)

Verified locally:
- pnpm typecheck / lint / build 모두 통과
- Actual E2E (host create + own row CRUD + pace 표시) 는 사용자가 OAuth
  발급 + admin 부트스트랩 끝낸 다음 검증

Out of scope (별 PR):
- 사진 업로드 (#21)
- 아카이브 리스트 (#11)
- 세션 수정/삭제 UI (다음)
- ADMIN이 타인 행 수정 (Week 3 stretch)
- OG 이미지 (#15)
- 검색·필터 (#14)

Spec: .gjc/specs/session-create-and-participation.md
…served) (#27)

Closes #21.

호스트(또는 ADMIN)가 세션 상세에서 단체사진 1장을 업로드. 원본 그대로
N100의 호스트 볼륨에 저장, next/image 로 서빙. 클라이언트 리사이즈/변환 X.

lib/uploads.ts:
- ALLOWED_MIME_TYPES (jpeg/png/webp/heic/heif) + MAX_UPLOAD_BYTES (15MB)
- getUploadsDir: env or ./uploads (절대경로 resolve)
- resolveUploadPath: prefix 검증으로 path traversal (../) 차단
- saveUploadedFile: 16-byte random hex id + MIME 기반 확장자
  → ${UPLOADS_DIR}/sessions/yyyy/mm/<id>.<ext>
- deleteUploadedFile: best-effort (DB가 진실의 원천)
- contentTypeFromExt, pathToEtag

lib/guard.ts:
- requireHostOrAdmin(sessionId) 추가 — 호스트 본인 또는 ADMIN, 아니면
  notFound() (페이지 존재 자체를 다른 멤버에게 노출 안 함)

Server actions (app/sessions/[id]/photo-actions.ts):
- uploadSessionPhoto: requireHostOrAdmin → File 검증/저장 → DB update
  → 기존 파일 best-effort 삭제 → revalidate
- removeSessionPhoto: requireHostOrAdmin → DB clear → best-effort 파일 삭제

Streaming route (app/api/uploads/[...path]/route.ts):
- GET /api/uploads/sessions/yyyy/mm/<id>.<ext>
- runtime: nodejs (Edge 아님 — fs 사용)
- requireApproved 가드 — 승인된 멤버만 사진 조회
- decodeURIComponent + resolveUploadPath 로 traversal 차단
- ETag + Cache-Control: private, max-age=31536000, immutable
- Readable.toWeb 로 stream 응답

UI:
- PhotoSection (server component):
  - 사진 있음 → next/image (unoptimized, /api/uploads 직접 서빙)
  - 사진 있음 + canEdit → 우측에 [교체] (PhotoReplaceButton) + [삭제]
  - 사진 없음 + canEdit → 업로드 폼
  - 사진 없음 + 비편집자 → '사진 없음' placeholder
- PhotoReplaceButton (client component, onChange handler 필요): 파일 선택
  즉시 form.requestSubmit() — UX 부드러움

app/sessions/[id]/page.tsx:
- placeholder section을 PhotoSection 으로 교체

env:
- .env.example 의 UPLOADS_DIR=./uploads uncomment (이전엔 주석)

.gitignore:
- /uploads/, /data/, /prisma/data/ 로 root anchor (이전 'uploads/' 가
  app/api/uploads/ 까지 ignore 시키던 버그 수정)
- *.db, *.db-journal 추가

Verified locally:
- pnpm typecheck / lint / build 모두 통과
- 9 routes 정상 (+ /api/uploads/[...path])
- E2E (실제 업로드 + 조회) 는 OAuth 발급 후 사용자가 직접

Out of scope:
- 여러 장 갤러리 — single field 유지
- OG 이미지 비로그인 접근 — Week 3 stretch (#15)
- HEIC → jpg 변환 (Safari 외 못 봄) — Week 3 stretch
- Orphan 파일 cleanup cron — Week 3

Spec: .gjc/specs/session-photo-upload.md
…ement Int (#28)

* fix(sessions): silently noop empty participation + drop encType from server-action forms

Two UX/correctness fixes found in dev E2E:

1) upsertParticipation(empty input):
   - Was throwing 'Error: 거리/기록/메모 중 최소 한 가지...' which surfaces
     as full-screen Next error overlay in dev (and would hit error.tsx in prod).
   - Fix: return early without throw. UX is mildly awkward (blank submit
     looks like nothing happened) but vastly better than an error page.
   - Inline error display is deferred to a follow-up PR (react-hook-form or
     useActionState) — PR #26 body already flagged this as OOS.

2) <form encType='multipart/form-data' action={serverAction}>:
   - React 19 warns 'Cannot specify encType for a form that specifies a
     function as the action' — React handles encoding automatically when
     action is a server action. The encType attribute is ignored, the
     warning fires (red dev indicator), but functionally upload still works.
   - Fix: remove encType from UploadForm + PhotoReplaceButton.
   - Multipart encoding is handled transparently by React's form action
     wiring; file inputs still work.

Verified:
- pnpm typecheck / lint / build pass
- Manual: empty participation submit → no error overlay, page stays as-is
- Manual: photo upload + replace still work (encType is auto-set by React)

* feat(schema): switch Session.id from cuid String to autoincrement Int

Closes follow-up question from PR #26/27 verification.

기존 cuid (/sessions/clxxxxx...) 대신 1, 2, 3 ... 순차 ID.
동아리 30명 내부용이라 enumeration 보안 가치보다 가독성이 큼.

Migration (auto-generated):
- Session.id: String cuid → Int autoincrement
- Participation.sessionId: String → Int (FK 타입 동기화)
- 다른 모델은 그대로 (User/Account/Participation.id 는 cuid 유지)

Code:
- z schemas: z.string().min(1).max(40) → z.coerce.number().int().positive()
  (form input은 여전히 string 으로 전송됨 — coerce 가 자동 변환)
- params.id: Number.parseInt + validity 검사 → notFound on NaN
- guard.requireHostOrAdmin(sessionId: number)
- 컴포넌트 props (MyParticipationForm/PhotoSection/PhotoReplaceButton):
  sessionId: number. JSX hidden input value 는 React 가 알아서 string 변환.

PR scope 메모:
이 PR 은 원래 hotfix 만이었는데 사용자 요청으로 schema 변경이 합류했음.
검증된 코드 측: typecheck/lint/build 통과, 마이그레이션 3개 적용 정상.
실 E2E (1, 2, 3 ... 로 매겨지는지) 는 머지 후 사용자가 직접.
dev 검증 중 발견된 두 UX 사고 해결.

1) 빈 입력/잘못된 입력에 사용자 알림 없음:
   - 이전엔 silent noop (hotfix #28) — 사용자가 뭘 잘못했는지 모름.
   - Fix: server action 시그니처를 useActionState 기반으로 변경하고
     검증 결과를 ActionResult({ ok, error })로 반환. 폼 상단 inline
     ErrorAlert (role='alert') 로 표시.
   - 검증 강화:
     - 거리: 0 초과, 1000 이하, 숫자만
     - 분: 0~1440 정수
     - 초: 0~59 정수 (60 이상은 분으로 표현하라는 의미)
     - 메모: 500자 이내
     - 거리/기록/메모 모두 빈 값 → 명시적 에러 메시지
   - 세션 생성도 같은 패턴: 장소 필수, 길이 한계 등 zod 메시지 그대로 사용.

2) 빠른 더블 클릭으로 row 중복 생성:
   - useFormStatus 기반 SubmitButton 으로 submit 중 자동 disabled +
     '저장 중...' / '만드는 중...' / '삭제 중...' 라벨 교체.
   - 첫 클릭 후 button 비활성 → 두 번째 클릭 무시.
   - 참여 행은 (sessionId, userId) unique 가 2차 안전망.

Touched:
- lib/pace.ts: parseDistanceInput/parseDurationInput 제거 → parseOptionalNumber
  하나로 통합 (min/max/field/integer 옵션). ParsedNumber discriminated union.
  splitDurationSec 는 유지 (prefill 용).
- app/sessions/new/actions.ts: createSession(prev, formData) => Promise<CreateSessionResult>
- app/sessions/new/_components/NewSessionForm.tsx: new client component,
  useActionState + SubmitButton.
- app/sessions/new/page.tsx: 폼 추출 → server 그대로, NewSessionForm 임포트.
- app/sessions/[id]/actions.ts: upsert/deleteParticipation 둘 다
  useActionState 시그니처. 강화된 검증.
- app/sessions/[id]/_components/MyParticipationForm.tsx: server → client,
  useActionState (upsert + delete 두 폼), SubmitButton.
- components/form/SubmitButton.tsx: useFormStatus 래퍼.
- components/form/ErrorAlert.tsx: role='alert' inline 배너.
- components/ui/button.tsx: ButtonProps export.

Verified:
- pnpm typecheck / lint / build 통과
- Build: /sessions/new 2.07kB (+1.6 from client form), /sessions/[id] 7.82kB
- 실 E2E (빈/음수/60+/문자 입력 알림, 더블 클릭 dedupe) 는 머지 후 사용자

OOS (별 PR):
- 사진 업로드 폼 inline error + dedupe (UX 결 다름)
- 필드별 에러 표시 (현재 첫 번째 에러 1개)
- 검증 실패 시 입력 보존 (defaultValue 의 부분 보존만 됨)

Spec: .gjc/specs/inline-validation-and-dedupe.md
Closes #11. + workflow docs 보강 (PR #28/29 사고 학습 반영).

/sessions:
- requireApproved 가드, 최신순 (date desc, id desc) keyset pagination,
  PAGE_SIZE=20, take +1 으로 다음 페이지 존재 판별
- SessionCard: next/image 16:9 썸네일 (없으면 회색 placeholder),
  날짜·장소·참여자수·호스트
- EmptyState: 빈 아카이브 시 CTA
- '더 보기' = Link 로 ?cursor=<id> 갱신 (JS 없이도 동작)
- lib/sessions.ts 신설 — listSessions(cursorId) 도메인 쿼리 한 곳에

홈 화면:
- '아카이브 리스트는 다음 PR' placeholder 제거
- '전체 아카이브' Link 활성화

워크플로우 보강 (같은 PR 안에 의도적 묶음):
- .gjc/skills/murun-feature/SKILL.md '4.5 폼/입력이 들어가는 경우' 추가:
  negative case alert / useActionState / useFormStatus / DB unique 등
  강제 체크리스트. PR #28/#29 누락 사고 재발 방지.
- docs/wiki/06-Checkpoints.md '#7 End-to-end smoke' 에 negative case
  명시 추가 — '빌드 그린은 시작점' 표어.

Verified:
- pnpm typecheck/lint/build 통과 (10 routes 정상)
- /sessions 173B + 111kB First Load (cursor URL 만으로 동작)
- E2E (세션 0/1/21+ 케이스, 사진 유무, 모바일) 는 머지 후 사용자

Out of scope (별 PR):
- 검색·필터 (#14)
- 사진 업로드 폼의 inline alert + dedupe (PR #29 OOS 그대로 — UX 결 다름)
- 무한 스크롤
- 시즌/월별 그룹화

Spec: .gjc/specs/sessions-archive-list.md
* fix(forms): SKILL 4.5 retrofit — photo & admin members inline alert + dedupe

PR #30 에 박은 폼 표준 체크리스트(SKILL.md §4.5)를 이미 있는 폼에 소급 적용.

Photo upload (app/sessions/[id]/photo-actions.ts + PhotoSection.tsx):
- uploadSessionPhoto / removeSessionPhoto 시그니처를 useActionState 패턴
  ((prev, formData) => Promise<{ ok, error? }>) 으로 변경.
- 검증 실패 (15MB 초과 / MIME 거부 / 빈 파일 / 세션 id 불량) 시 throw 대신
  명시적 PhotoResult.error 반환.
- PhotoSection 을 server → client component 로 (useActionState).
- 업로드/교체/삭제 모두 SubmitButton (useFormStatus) — pending 중 disabled
  + 라벨 교체.
- 사진 우측 [교체] 버튼은 부모의 useActionState formAction 을 props 로 받음
  → alert/pending 상태가 한 곳에서 관리됨.

Admin members (app/admin/members/actions.ts + page.tsx + _components):
- approveUser / rejectUser 시그니처 동일 패턴.
- 'cannot approve/reject self' 를 throw 대신 inline alert 메시지로.
- 페이지 (server component) 가 PendingMemberActions (client) 를 행마다 렌더.
- 두 버튼 ([승인]/[거절]) 이 같은 row 의 alert 영역 공유.

검증된 회귀 항목:
- 사진 업로드 [올리기] 빠른 더블 클릭 → 첫 클릭 후 button disabled.
- 본인 [승인] 시도 → '본인은 본인을 승인할 수 없습니다.' alert, 페이지 그대로.
- 빌드 9 → 10 routes, /sessions/[id] +1.2kB (client form 추가분).

Spec: .gjc/specs/week2-wrapup.md §1

* chore(ci): typecheck + lint + build on PR/push (#5)

.github/workflows/ci.yml — PR to dev/main 과 dev/main push 에서 동작.

Jobs:
- Setup Node 22 + pnpm 11.5.1 (lockfile 의 packageManager 와 일치)
- pnpm store 캐시 (lockfile hash 기반)
- .next/cache 캐시 (ts/tsx hash 기반)
- pnpm install --frozen-lockfile (lockfile drift 차단)
- pnpm db:generate (postinstall 보강)
- typecheck → lint → build 직렬
- build env: dummy 값 주입 (auth/db url 필요)

Concurrency: 같은 ref 의 새 push 가 들어오면 이전 run 취소.
Timeout: 10분.

브랜치 보호 (Settings → Branches) 설정으로 'check' 통과 강제는 호스트(나)
가 main, dev 두 곳에 별도 등록 — 코드 변경 X.

* chore(deploy): GH Actions SSH deploy pipeline (#22)

.github/workflows/deploy.yml + deploy/README.md secret 등록 절차.

Workflow:
- trigger: push to dev (→ staging), main (→ prod), 또는 workflow_dispatch
- build job: Docker Buildx + GHCR (ghcr.io) push, GHA cache 사용
  태그: '<target>-<sha7>' + '<target>' (latest 역할)
- deploy job: appleboy/ssh-action 으로 N100 접속,
  docker compose pull && up -d (project name 으로 staging/prod 분리)
- concurrency: 같은 ref 의 동시 배포 금지
- environment: GitHub Environments 의 'staging' / 'prod' 사용 (prod 는
  수동 승인 가능)
- Sanity check 단계: secret 누락 시 명시적 에러로 즉시 실패

필요 secrets (사용자 직접 등록):
- N100_HOST / N100_USER / N100_SSH_KEY
- GITHUB_TOKEN 은 자동 (GHCR push 권한 위해 permissions.packages: write)

deploy/README.md:
- '0. CI/CD 사용 시' 섹션 신설 — secret 등록 절차, SSH key 생성, prod
  environment 보호.
- 기존 '1. 첫 배포 (수동)' 은 'CI/CD 우회/디버깅' 으로 강등 (호스트 init
  1회는 여전히 필요)

* docs(retro): Week 2 회고 + Home toc
@peterabcd peterabcd merged commit 3e1a611 into main Jun 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant