Skip to content

Commit 31ed6d7

Browse files
committed
feat: finalize week3 workflow and session editing
1 parent d8b9c2f commit 31ed6d7

33 files changed

Lines changed: 1490 additions & 156 deletions

.env.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
1212
DATABASE_URL=file:./data/murun.db
1313

1414
# --- Auth.js v5 ---
15-
# NEXTAUTH_URL 은 base URL. dev=localhost:3000, prod=https://murun.duckdns.org
15+
# Auth.js base URL. local=http://localhost:3000, prod=https://murun.duckdns.org
1616
AUTH_URL=http://localhost:3000
1717
# 생성: openssl rand -base64 32
1818
AUTH_SECRET=
@@ -22,8 +22,9 @@ AUTH_TRUST_HOST=true
2222

2323
# --- Google OAuth ---
2424
# Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID
25-
# Authorized redirect URI: http://localhost:3000/api/auth/callback/google
26-
# (배포 후엔 https://<도메인>/api/auth/callback/google 도 추가)
25+
# Authorized redirect URI:
26+
# local: http://localhost:3000/api/auth/callback/google
27+
# prod : https://murun.duckdns.org/api/auth/callback/google
2728
AUTH_GOOGLE_ID=
2829
AUTH_GOOGLE_SECRET=
2930
# 이메일 도메인 강제. snu.ac.kr 이외는 reject.

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ Closes #
1515

1616
### 공통
1717
- [ ] diff 처음부터 끝까지 읽음
18-
- [ ] `pnpm typecheck` / `pnpm lint` / `pnpm build` 통과
19-
- [ ] preview deploy URL에서 시나리오 1회 실행
20-
- [ ] 새 env / dep 있다면 `.env.example`·README 업데이트
18+
- [ ] `pnpm typecheck` / `pnpm lint` / `pnpm test` / `pnpm build` 통과
19+
- [ ] 사용자 흐름 변경이면 `pnpm test:e2e` 또는 prod/로컬 smoke 1회 실행
20+
- [ ] 새 env / dep / script 있다면 `.env.example`·README·Wiki 업데이트
2121

2222
### UI 변경 시
2323
- [ ] 모바일 375px 가로 스크롤 없음
@@ -26,8 +26,9 @@ Closes #
2626

2727
### 데이터 쓰기 시
2828
- [ ] Server Action 첫 줄에 인증·권한 체크
29-
- [ ] zod 스키마 통과
29+
- [ ] zod 또는 input parser 검증 통과
3030
- [ ] 중복 생성 차단 (DB unique 또는 트랜잭션)
31+
- [ ] negative case(빈 값/범위 밖/권한 없음)를 테스트나 smoke로 확인
3132

3233
### Schema/Migration 시
3334
- [ ] migration 이름 의미 있음

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ concurrency:
1111
group: ci-${{ github.workflow }}-${{ github.ref }}
1212
cancel-in-progress: true
1313

14+
env:
15+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
16+
1417
jobs:
1518
check:
1619
name: typecheck + lint + build

.github/workflows/deploy.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ permissions:
3232
env:
3333
REGISTRY: ghcr.io
3434
IMAGE: ${{ github.repository }}
35+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
3536

3637
jobs:
3738
# ---------------------------------------------------------------------------
@@ -164,4 +165,17 @@ jobs:
164165
trap on_error ERR
165166
docker compose -p murun-${{ needs.build.outputs.target }} --env-file "$MURUN_ENV_FILE" pull
166167
docker compose -p murun-${{ needs.build.outputs.target }} --env-file "$MURUN_ENV_FILE" up -d --remove-orphans
168+
echo "::group::post-deploy smoke"
169+
for i in $(seq 1 30); do
170+
if docker compose -p murun-${{ needs.build.outputs.target }} --env-file "$MURUN_ENV_FILE" exec -T app node -e "fetch('http://127.0.0.1:3000/api/health').then(async (r) => { const j = await r.json(); if (!r.ok || !j.ok) process.exit(1); }).catch((e) => { console.error(e); process.exit(1); })"; then
171+
break
172+
fi
173+
if [ "$i" -eq 30 ]; then
174+
echo "health smoke failed"
175+
exit 1
176+
fi
177+
sleep 2
178+
done
179+
docker compose -p murun-${{ needs.build.outputs.target }} --env-file "$MURUN_ENV_FILE" exec -T app node -e "fetch('http://127.0.0.1:3000/login?error=Configuration').then(async (r) => { const text = await r.text(); if (!r.ok || !text.includes('서버의 Google 로그인 설정')) process.exit(1); }).catch((e) => { console.error(e); process.exit(1); })"
180+
echo "::endgroup::"
167181
docker image prune -f

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ PR 규칙:
4646
| 작업 단위 | feature 단위 (vertical slice) — [`docs/wiki/04-Agent-Workflow.md`](./docs/wiki/04-Agent-Workflow.md) 참고 |
4747
| Agent skill | [`.gjc/skills/murun-feature/SKILL.md`](./.gjc/skills/murun-feature/SKILL.md) |
4848
| Week 3 워크플로우 리포트 | [`docs/wiki/09-Agent-Workflow-Report.md`](./docs/wiki/09-Agent-Workflow-Report.md) |
49-
| 회고 | [`docs/wiki/Retrospective-Week1.md`](./docs/wiki/Retrospective-Week1.md), [`docs/wiki/Retrospective-Week2.md`](./docs/wiki/Retrospective-Week2.md) |
49+
| 회고 | [`docs/wiki/Retrospective-Week1.md`](./docs/wiki/Retrospective-Week1.md), [`docs/wiki/Retrospective-Week2.md`](./docs/wiki/Retrospective-Week2.md), [`docs/wiki/08-Retrospective-Week3.md`](./docs/wiki/08-Retrospective-Week3.md) |
50+
| 후속 TODO | [`docs/wiki/10-Backlog-and-Decisions.md`](./docs/wiki/10-Backlog-and-Decisions.md) |
5051

5152
## 5. 빠른 시작
5253

@@ -81,7 +82,7 @@ pnpm db:studio # 또는 sqlite3 ./data/murun.db
8182
- Application type: Web application
8283
- Authorized redirect URIs:
8384
- `http://localhost:3000/api/auth/callback/google`
84-
- (배포 후) `https://<도메인>/api/auth/callback/google`
85+
- `https://murun.duckdns.org/api/auth/callback/google`
8586

8687
발급된 client ID/secret 을 `.env.local``AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` 에 넣기. `AUTH_SECRET``openssl rand -base64 32` 로 생성.
8788

app/api/uploads/[...path]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// 업로드된 파일 스트리밍 라우트.
22
// - GET /api/uploads/sessions/yyyy/mm/<id>.<ext>
3-
// - 인증: requireApproved (승인된 멤버만 사진 조회). OG 이미지(비로그인 허용)는
4-
// Week 3 stretch 에서 별도 라우트로 처리.
3+
// - 인증: requireApproved (승인된 멤버만 사진 조회). 비로그인 OG 크롤러는
4+
// /sessions/[id]/opengraph-image 가 별도 public 이미지로 처리한다.
55
// - 보안: path traversal 차단을 위해 resolveUploadPath 로 prefix 검증.
66

77
import { createReadStream } from "node:fs";

app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
22
import "./globals.css";
33

44
// NEXT_PUBLIC_APP_URL 이 비어 있으면 Next 기본(요청 host)을 쓴다.
5-
// staging/prod 의 .env 에 외부 URL 을 넣어두면 OG 이미지/링크가 그 URL 기준이 됨.
5+
// local/prod 의 .env 에 외부 URL 을 넣어두면 OG 이미지/링크가 그 URL 기준이 됨.
66
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
77

88
export const metadata: Metadata = {

app/sessions/[id]/_components/MyParticipationForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function MyParticipationForm({ sessionId, existing }: Props) {
5959
name="distanceKm"
6060
type="number"
6161
inputMode="decimal"
62-
step="0.1"
62+
step="0.01"
6363
min="0.01"
6464
max="1000"
6565
placeholder="예: 5.0"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client";
2+
3+
import { useActionState } from "react";
4+
5+
import { ErrorAlert } from "@/components/form/ErrorAlert";
6+
import { SubmitButton } from "@/components/form/SubmitButton";
7+
import { Input } from "@/components/ui/input";
8+
import { Label } from "@/components/ui/label";
9+
import { Textarea } from "@/components/ui/textarea";
10+
11+
import { deleteSession, updateSession } from "../actions";
12+
13+
type Props = {
14+
session: {
15+
id: number;
16+
date: string;
17+
startTime: string;
18+
location: string;
19+
weather: string;
20+
notes: string;
21+
};
22+
};
23+
24+
export function EditSessionForm({ session }: Props) {
25+
const [updateState, updateAction] = useActionState(updateSession, null);
26+
const [deleteState, deleteAction] = useActionState(deleteSession, null);
27+
const updateError = updateState && !updateState.ok ? updateState.error : null;
28+
const deleteError = deleteState && !deleteState.ok ? deleteState.error : null;
29+
30+
return (
31+
<div className="flex flex-col gap-8">
32+
<form action={updateAction} className="flex flex-col gap-5 rounded-md border p-4">
33+
<input type="hidden" name="sessionId" value={session.id} />
34+
<ErrorAlert message={updateError} />
35+
36+
<div className="flex flex-col gap-2">
37+
<Label htmlFor="date">날짜</Label>
38+
<Input id="date" name="date" type="date" defaultValue={session.date} required />
39+
</div>
40+
41+
<div className="flex flex-col gap-2">
42+
<Label htmlFor="startTime">시작 시간 (선택)</Label>
43+
<Input
44+
id="startTime"
45+
name="startTime"
46+
type="time"
47+
defaultValue={session.startTime}
48+
placeholder="예: 19:30"
49+
/>
50+
</div>
51+
52+
<div className="flex flex-col gap-2">
53+
<Label htmlFor="location">장소</Label>
54+
<Input
55+
id="location"
56+
name="location"
57+
type="text"
58+
required
59+
maxLength={120}
60+
defaultValue={session.location}
61+
placeholder="예: 서울숲 입구"
62+
/>
63+
</div>
64+
65+
<div className="flex flex-col gap-2">
66+
<Label htmlFor="weather">날씨 (선택)</Label>
67+
<Input
68+
id="weather"
69+
name="weather"
70+
type="text"
71+
maxLength={120}
72+
defaultValue={session.weather}
73+
placeholder="예: 맑음 / 22°C"
74+
/>
75+
</div>
76+
77+
<div className="flex flex-col gap-2">
78+
<Label htmlFor="notes">코스 메모 (선택)</Label>
79+
<Textarea
80+
id="notes"
81+
name="notes"
82+
maxLength={2000}
83+
defaultValue={session.notes}
84+
placeholder="코스, 페이스 권장, 모임 후 식당 등"
85+
rows={5}
86+
/>
87+
</div>
88+
89+
<SubmitButton idleLabel="세션 수정" pendingLabel="저장 중..." />
90+
</form>
91+
92+
<form action={deleteAction} className="flex flex-col gap-4 rounded-md border border-destructive/40 bg-destructive/5 p-4">
93+
<input type="hidden" name="sessionId" value={session.id} />
94+
<div className="flex flex-col gap-1">
95+
<h2 className="text-sm font-semibold text-destructive">세션 삭제</h2>
96+
<p className="text-sm text-muted-foreground">
97+
세션, 참여 기록, 단체사진 파일을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.
98+
</p>
99+
</div>
100+
<ErrorAlert message={deleteError} />
101+
<label className="flex items-center gap-2 text-sm">
102+
<input
103+
type="checkbox"
104+
name="confirmDelete"
105+
value="yes"
106+
className="size-4"
107+
/>
108+
이 세션을 삭제한다는 것을 확인했습니다.
109+
</label>
110+
<SubmitButton
111+
variant="destructive"
112+
idleLabel="세션 삭제"
113+
pendingLabel="삭제 중..."
114+
/>
115+
</form>
116+
</div>
117+
);
118+
}

app/sessions/[id]/edit/actions.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { redirect } from "next/navigation";
5+
import { z } from "zod";
6+
7+
import { db } from "@/lib/db";
8+
import { requireHostOrAdmin } from "@/lib/guard";
9+
import { parseSessionForm } from "@/lib/session-form";
10+
import { deleteUploadedFile } from "@/lib/uploads";
11+
12+
export type EditSessionResult =
13+
| { ok: true }
14+
| { ok: false; error: string };
15+
16+
const sessionIdSchema = z.coerce.number().int().positive();
17+
18+
function parseSessionId(formData: FormData): EditSessionResult & { value?: number } {
19+
const parsed = sessionIdSchema.safeParse(formData.get("sessionId"));
20+
if (!parsed.success) {
21+
return { ok: false, error: "세션 ID 가 올바르지 않습니다." };
22+
}
23+
return { ok: true, value: parsed.data };
24+
}
25+
26+
export async function updateSession(
27+
_prev: EditSessionResult | null,
28+
formData: FormData,
29+
): Promise<EditSessionResult> {
30+
const sid = parseSessionId(formData);
31+
if (!sid.ok) return sid;
32+
const sessionId = sid.value!;
33+
34+
await requireHostOrAdmin(sessionId);
35+
36+
const parsed = parseSessionForm(formData);
37+
if (!parsed.ok) return parsed;
38+
39+
await db.session.update({
40+
where: { id: sessionId },
41+
data: parsed.value,
42+
});
43+
44+
revalidatePath(`/sessions/${sessionId}`);
45+
revalidatePath(`/sessions/${sessionId}/edit`);
46+
revalidatePath("/sessions");
47+
redirect(`/sessions/${sessionId}`);
48+
}
49+
50+
export async function deleteSession(
51+
_prev: EditSessionResult | null,
52+
formData: FormData,
53+
): Promise<EditSessionResult> {
54+
const sid = parseSessionId(formData);
55+
if (!sid.ok) return sid;
56+
const sessionId = sid.value!;
57+
58+
await requireHostOrAdmin(sessionId);
59+
60+
if (formData.get("confirmDelete") !== "yes") {
61+
return { ok: false, error: "삭제 확인 체크박스를 선택하세요." };
62+
}
63+
64+
const existing = await db.session.findUnique({
65+
where: { id: sessionId },
66+
select: { groupPhotoPath: true },
67+
});
68+
if (!existing) {
69+
return { ok: false, error: "세션을 찾을 수 없습니다." };
70+
}
71+
72+
await db.session.delete({ where: { id: sessionId } });
73+
74+
if (existing.groupPhotoPath) {
75+
await deleteUploadedFile(existing.groupPhotoPath);
76+
}
77+
78+
revalidatePath("/sessions");
79+
redirect("/sessions");
80+
}

0 commit comments

Comments
 (0)