Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=file:./data/murun.db

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

# --- Google OAuth ---
# Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID
# Authorized redirect URI: http://localhost:3000/api/auth/callback/google
# (배포 후엔 https://<도메인>/api/auth/callback/google 도 추가)
# Authorized redirect URI:
# local: http://localhost:3000/api/auth/callback/google
# prod : https://murun.duckdns.org/api/auth/callback/google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
# 이메일 도메인 강제. snu.ac.kr 이외는 reject.
Expand Down
9 changes: 5 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Closes #

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

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

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

### Schema/Migration 시
- [ ] migration 이름 의미 있음
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
check:
name: typecheck + lint + build
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ permissions:
env:
REGISTRY: ghcr.io
IMAGE: ${{ github.repository }}
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -164,4 +165,17 @@ jobs:
trap on_error ERR
docker compose -p murun-${{ needs.build.outputs.target }} --env-file "$MURUN_ENV_FILE" pull
docker compose -p murun-${{ needs.build.outputs.target }} --env-file "$MURUN_ENV_FILE" up -d --remove-orphans
echo "::group::post-deploy smoke"
for i in $(seq 1 30); do
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
break
fi
if [ "$i" -eq 30 ]; then
echo "health smoke failed"
exit 1
fi
sleep 2
done
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); })"
echo "::endgroup::"
docker image prune -f
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ PR 규칙:
| 작업 단위 | feature 단위 (vertical slice) — [`docs/wiki/04-Agent-Workflow.md`](./docs/wiki/04-Agent-Workflow.md) 참고 |
| Agent skill | [`.gjc/skills/murun-feature/SKILL.md`](./.gjc/skills/murun-feature/SKILL.md) |
| Week 3 워크플로우 리포트 | [`docs/wiki/09-Agent-Workflow-Report.md`](./docs/wiki/09-Agent-Workflow-Report.md) |
| 회고 | [`docs/wiki/Retrospective-Week1.md`](./docs/wiki/Retrospective-Week1.md), [`docs/wiki/Retrospective-Week2.md`](./docs/wiki/Retrospective-Week2.md) |
| 회고 | [`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) |
| 후속 TODO | [`docs/wiki/10-Backlog-and-Decisions.md`](./docs/wiki/10-Backlog-and-Decisions.md) |

## 5. 빠른 시작

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

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

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

import { createReadStream } from "node:fs";
Expand Down
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";

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

export const metadata: Metadata = {
Expand Down
2 changes: 1 addition & 1 deletion app/sessions/[id]/_components/MyParticipationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function MyParticipationForm({ sessionId, existing }: Props) {
name="distanceKm"
type="number"
inputMode="decimal"
step="0.1"
step="0.01"
min="0.01"
max="1000"
placeholder="예: 5.0"
Expand Down
118 changes: 118 additions & 0 deletions app/sessions/[id]/edit/_components/EditSessionForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use client";

import { useActionState } from "react";

import { ErrorAlert } from "@/components/form/ErrorAlert";
import { SubmitButton } from "@/components/form/SubmitButton";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";

import { deleteSession, updateSession } from "../actions";

type Props = {
session: {
id: number;
date: string;
startTime: string;
location: string;
weather: string;
notes: string;
};
};

export function EditSessionForm({ session }: Props) {
const [updateState, updateAction] = useActionState(updateSession, null);
const [deleteState, deleteAction] = useActionState(deleteSession, null);
const updateError = updateState && !updateState.ok ? updateState.error : null;
const deleteError = deleteState && !deleteState.ok ? deleteState.error : null;

return (
<div className="flex flex-col gap-8">
<form action={updateAction} className="flex flex-col gap-5 rounded-md border p-4">
<input type="hidden" name="sessionId" value={session.id} />
<ErrorAlert message={updateError} />

<div className="flex flex-col gap-2">
<Label htmlFor="date">날짜</Label>
<Input id="date" name="date" type="date" defaultValue={session.date} required />
</div>

<div className="flex flex-col gap-2">
<Label htmlFor="startTime">시작 시간 (선택)</Label>
<Input
id="startTime"
name="startTime"
type="time"
defaultValue={session.startTime}
placeholder="예: 19:30"
/>
</div>

<div className="flex flex-col gap-2">
<Label htmlFor="location">장소</Label>
<Input
id="location"
name="location"
type="text"
required
maxLength={120}
defaultValue={session.location}
placeholder="예: 서울숲 입구"
/>
</div>

<div className="flex flex-col gap-2">
<Label htmlFor="weather">날씨 (선택)</Label>
<Input
id="weather"
name="weather"
type="text"
maxLength={120}
defaultValue={session.weather}
placeholder="예: 맑음 / 22°C"
/>
</div>

<div className="flex flex-col gap-2">
<Label htmlFor="notes">코스 메모 (선택)</Label>
<Textarea
id="notes"
name="notes"
maxLength={2000}
defaultValue={session.notes}
placeholder="코스, 페이스 권장, 모임 후 식당 등"
rows={5}
/>
</div>

<SubmitButton idleLabel="세션 수정" pendingLabel="저장 중..." />
</form>

<form action={deleteAction} className="flex flex-col gap-4 rounded-md border border-destructive/40 bg-destructive/5 p-4">
<input type="hidden" name="sessionId" value={session.id} />
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold text-destructive">세션 삭제</h2>
<p className="text-sm text-muted-foreground">
세션, 참여 기록, 단체사진 파일을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.
</p>
</div>
<ErrorAlert message={deleteError} />
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="confirmDelete"
value="yes"
className="size-4"
/>
이 세션을 삭제한다는 것을 확인했습니다.
</label>
<SubmitButton
variant="destructive"
idleLabel="세션 삭제"
pendingLabel="삭제 중..."
/>
</form>
</div>
);
}
80 changes: 80 additions & 0 deletions app/sessions/[id]/edit/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

import { db } from "@/lib/db";
import { requireHostOrAdmin } from "@/lib/guard";
import { parseSessionForm } from "@/lib/session-form";
import { deleteUploadedFile } from "@/lib/uploads";

export type EditSessionResult =
| { ok: true }
| { ok: false; error: string };

const sessionIdSchema = z.coerce.number().int().positive();

function parseSessionId(formData: FormData): EditSessionResult & { value?: number } {
const parsed = sessionIdSchema.safeParse(formData.get("sessionId"));
if (!parsed.success) {
return { ok: false, error: "세션 ID 가 올바르지 않습니다." };
}
return { ok: true, value: parsed.data };
}

export async function updateSession(
_prev: EditSessionResult | null,
formData: FormData,
): Promise<EditSessionResult> {
const sid = parseSessionId(formData);
if (!sid.ok) return sid;
const sessionId = sid.value!;

await requireHostOrAdmin(sessionId);

const parsed = parseSessionForm(formData);
if (!parsed.ok) return parsed;

await db.session.update({
where: { id: sessionId },
data: parsed.value,
});

revalidatePath(`/sessions/${sessionId}`);
revalidatePath(`/sessions/${sessionId}/edit`);
revalidatePath("/sessions");
redirect(`/sessions/${sessionId}`);
}

export async function deleteSession(
_prev: EditSessionResult | null,
formData: FormData,
): Promise<EditSessionResult> {
const sid = parseSessionId(formData);
if (!sid.ok) return sid;
const sessionId = sid.value!;

await requireHostOrAdmin(sessionId);

if (formData.get("confirmDelete") !== "yes") {
return { ok: false, error: "삭제 확인 체크박스를 선택하세요." };
}

const existing = await db.session.findUnique({
where: { id: sessionId },
select: { groupPhotoPath: true },
});
if (!existing) {
return { ok: false, error: "세션을 찾을 수 없습니다." };
}

await db.session.delete({ where: { id: sessionId } });

if (existing.groupPhotoPath) {
await deleteUploadedFile(existing.groupPhotoPath);
}

revalidatePath("/sessions");
redirect("/sessions");
}
Loading
Loading