lig-room
by MOSW626
README.md
# LIG 룸 예약 시스템
> **이 문서는 개발자·운영자용이다.** 공간을 쓰는 이용자용 안내(예약하는 법, 승인 대기, 반복 예약, 계정·문의)는 서비스 안 [`/help`](https://lig-room.vercel.app/help)에 있다 — 로그인 없이 열린다.
책상 4개(지정석 2 + 예약석 2) 공간의 예약·승인 시스템. 설계는 [DESIGN.md](./DESIGN.md), 배포는 [DEPLOY.md](./DEPLOY.md).
**운영 주소**: https://lig-room.vercel.app (Vercel + Neon Postgres, 프로젝트 `mosw626s-projects/lig-room`)
로컬 개발도 같은 Neon DB를 사용한다(.env).
## 로컬 실행
```bash
npm install
npx prisma migrate dev # 최초 1회 (시드 자동 실행)
npm run dev
```
- 시드는 관리자 계정(`admin@lig.local`)을 만들며 무작위 비밀번호를 콘솔에 1회 출력한다(`SEED_ADMIN_PASSWORD`로 지정 가능). 운영 DB에서는 이미 삭제된 계정이다.
- 일반 사용자는 `/signup`에서 가입 → `/admin` 사용자 관리에서 등급 변경(`MEMBER` / `SUPER`(자동 승인) / `ADMIN`)
- 비밀번호는 이 문서에 적지 않는다. 분실 시 처리는 아래 "흐름" 마지막 문단 참고
## 테스트
```bash
npm test # 이름 마스킹 · 반복 회차 계산 · KST 시각 처리(TZ=UTC로 실행)
```
## 이메일 알림 켜는 법
1. [Resend](https://resend.com) 가입 후 API 키 발급
2. Vercel 환경변수에 `RESEND_API_KEY` 추가 (발신 주소를 바꾸려면 `MAIL_FROM`도)
3. 키가 있으면 자동 활성화 — 없으면 인앱 알림만 동작하고 메일은 조용히 건너뛴다
발신 도메인을 검증하기 전에는 Resend가 **가입 계정 주소로만** 보낼 수 있다.
관리자 본인만 알림을 받으면 되는 단계라면 `MAIL_ONLY_TO`에 그 주소를 넣는다 —
그 주소로만 발송하고 나머지는 건너뛰므로 실패 로그가 쌓이지 않는다.
모두에게 보내려면 도메인을 사서 Resend에 등록하고 `MAIL_FROM`을 그 도메인 주소로 바꾼다.
## 폰 알림(웹 푸시) 켜는 법
관리자가 메일을 자주 못 볼 때를 위해, 새 예약 신청·승인·거절 알림을 **폰 잠금화면**으로 바로 보낸다.
`lib/notify.ts`의 `notify()`가 인앱 알림·메일과 나란히 푸시도 보내므로 호출부는 손댈 것이 없다.
### 환경변수
| 변수 | 설명 |
|---|---|
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | VAPID 공개키. 브라우저가 구독을 만들 때 쓴다(빌드에 그대로 박힌다 — 비밀이 아니다) |
| `VAPID_PRIVATE_KEY` | VAPID 개인키. **비밀** — 서버에서만 쓴다 |
| `VAPID_SUBJECT` | 푸시 서비스가 문제 시 연락할 주소. `mailto:ys.an@kaist.ac.kr` |
키 쌍은 `npx web-push generate-vapid-keys`로 만든다. **키를 바꾸면 기존 구독이 전부 무효**가 되어
모든 기기에서 다시 켜야 한다. 셋 중 하나라도 없으면 푸시는 조용히 no-op이고(메일과 같은 원칙),
발송 실패는 절대 throw하지 않는다 — 예약 트랜잭션을 깨뜨리지 않기 위해서다.
### 켜기 (이용자)
`/account` → "알림" → **이 기기에서 알림 받기** → 권한 허용 → **테스트 알림 보내기**로 확인.
기기마다 따로 켜야 하고(폰·노트북 각각), 기기 목록에서 개별 해제할 수 있다.
**아이폰은 홈 화면에 추가한 PWA 안에서만 동작한다(iOS 16.4+).** 사파리 탭에서는 `PushManager`가
아예 없다. 그래서 `public/manifest.json`과 아이콘, `apple-touch-icon`이 필수다 — 이 자산들은
브라우저가 쿠키 없이 가져가므로 `proxy.ts` matcher에서 로그인 검사를 면제해 두었다.
### 구성
- `public/sw.js` — 서비스워커(순수 JS, 빌드 파이프라인 밖). `push`로 알림 표시, `notificationclick`으로
이미 열린 탭을 포커스하거나 새로 연다. `tag`로 같은 화면 알림이 쌓이지 않게 대체한다.
- `lib/push.ts` — `sendPush(userId, {title, body, url})`. 그 사용자의 모든 구독에 발송하고,
**404/410이 오면 그 구독을 DB에서 삭제한다**(기기 초기화·브라우저 데이터 삭제로 남은 죽은 구독 정리).
- `PushSubscription` 테이블 — 구독 1행 = 기기 1대. `endpoint`가 unique 키다.
## AI 에이전트에게 예약 맡기기
Claude 같은 AI 에이전트가 대신 빈자리를 찾아 예약을 신청하고, 관리자라면 승인·거절까지 할 수 있다. 설계는 [AGENT-API.md](./AGENT-API.md).
### 1. 키 발급
로그인 후 [`/account`](https://lig-room.vercel.app/account) → "AI 에이전트 연결"에서 이름·스코프·만료(기본 90일)를 정해 발급한다. **평문 키(`lig_sk_...`)는 그 화면에서 딱 한 번만 보인다** — 서버에는 해시만 남아 다시 볼 수 없다.
스코프는 필요한 것만 고른다.
| 스코프 | 할 수 있는 일 |
|---|---|
| `reservations:read` | 책상·빈자리·내 예약 조회 |
| `reservations:write` | 예약 신청·취소 (본인 것만) |
| `admin:approve` | 대기 목록 조회, 승인·거절·재배정 (**ADMIN 계정만**) |
키에 없는 스코프의 기능은 에이전트에게 아예 보이지 않는다(읽기 전용 키로 붙으면 도구 목록에 예약 신청 도구가 나타나지 않는다). 관리자 도구는 `admin:approve` 스코프와 ADMIN 등급이 **둘 다** 있을 때만 보이고, 등급은 요청마다 DB에서 다시 확인한다.
### 2. Claude Code에 붙이기
```bash
claude mcp add --transport http lig-room https://lig-room.vercel.app/api/mcp \
--header "Authorization: Bearer lig_sk_..."
```
붙었는지 확인은 `claude mcp list`, 떼어내려면 `claude mcp remove lig-room`.
### 3. Claude 데스크톱·웹에 붙이기 (커스텀 커넥터)
설정 → 커넥터 → **커넥터 추가** → 원격 MCP 서버 URL에 `https://lig-room.vercel.app/api/mcp`를 넣고, 헤더에 `Authorization: Bearer lig_sk_...`를 추가한다. OAuth가 아니라 헤더 방식이므로 로그인 창은 뜨지 않는다.
붙으면 이렇게 쓸 수 있다.
> "다음 주 화요일 오후에 빈 책상 있어? 있으면 2시부터 4시까지 잡아줘."
### 4. 로컬 LLM·자작 스크립트용 (curl)
MCP는 JSON-RPC 2.0을 POST 하나로 주고받는다. SDK 없이도 붙는다.
```bash
KEY="lig_sk_..."
# 쓸 수 있는 도구 목록 (스코프에 따라 걸러져서 온다)
curl -s https://lig-room.vercel.app/api/mcp \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 빈 시간 조회 (시각은 KST 오프셋 ISO 8601)
curl -s https://lig-room.vercel.app/api/mcp \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"find_available_slots",
"arguments":{"from":"2026-08-25T00:00:00+09:00","to":"2026-08-26T00:00:00+09:00"}}}'
# 예약 신청 — 응답의 status·needsApproval을 꼭 확인할 것
curl -s https://lig-room.vercel.app/api/mcp \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_reservation",
"arguments":{"deskId":"<책상 id>","startsAt":"2026-08-25T14:00:00+09:00","endsAt":"2026-08-25T16:00:00+09:00"}}}'
```
MCP가 부담스러우면 같은 일을 하는 REST API(`/api/v1/*`)도 있다.
```bash
curl -s https://lig-room.vercel.app/api/v1/me -H "Authorization: Bearer $KEY"
```
AI가 읽을 안내는 [`/llms.txt`](https://lig-room.vercel.app/llms.txt), 스펙은 [`/api/v1/openapi.json`](https://lig-room.vercel.app/api/v1/openapi.json)에 있다(둘 다 로그인 없이 열린다).
### 주의사항
- **키는 비밀번호와 같다.** 채팅·이슈·저장소에 붙여넣지 말고, 남에게 공유하지 말 것. 키를 가진 사람은 당신 이름으로 예약할 수 있다. 새어 나갔다 싶으면 `/account`에서 즉시 폐기하고 새로 발급하면 된다(마지막 사용 시각도 함께 보인다).
- **에이전트가 한 일도 그대로 기록된다.** 감사 로그에 `(API: 키이름)`으로 남아 웹에서 한 것과 구분되고, 신청·승인·거절·재배정은 당사자에게 인앱·메일 알림이 똑같이 나간다. 에이전트가 조용히 처리하는 일은 없다.
- **신청은 확정이 아니다.** 일반 회원의 신청은 관리자 승인 대기(PENDING) 상태다. 에이전트가 "예약했다"고만 말하면 대기 중인지 확정인지 다시 물어보자(도구 설명에 그대로 보고하라고 적어 두었다).
- **승인은 사람이 되돌릴 수 있다.** 에이전트가 승인한 예약도 관리자가 웹 `/admin`에서 취소할 수 있다. 거절만은 되돌릴 수 없어 신청자가 새로 신청해야 한다.
- **에이전트가 못 하는 일**: 사용자 등급 변경, 임시 비밀번호 발급, 책상 배치 변경, 계정 삭제. 계정 통제권은 API로 넘기지 않는다.
- 레이트 리밋은 키당 분당 60회(쓰기 10회)다. 초과하면 429가 오고, 언제 다시 시도할지 응답에 적혀 있다.
## 흐름
가입/로그인 → `/reserve`에서 예약석·시간 신청(PENDING) → 관리자에게 알림 → `/admin`에서 승인·거절·재배정 → 신청자에게 알림 → 대시보드에서 본인 예약 확인·취소
비밀번호 분실 시: 관리자가 `/admin` 사용자 관리에서 "임시 비밀번호 발급" → 화면에 1회 표시되는 평문을 본인에게 직접 전달 → 본인은 그 값으로 로그인하면 `/account/new-password`로 강제 이동되어 새 비밀번호를 설정해야 다른 기능을 쓸 수 있다.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues