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
16 changes: 12 additions & 4 deletions 0028.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
> 인덱스·보존기간 명시, §2 미인증 영업자 한계의 운영 정책 상호참조.
> 4차 개정(P0 구현 후): **자체 도메인 필수** — supabase.co 는 게이트웨이가 HTML 서빙을
> 차단(실기기 QR 테스트에서 발견). `pawmate.kr` 확보, `go.pawmate.kr` Worker 프록시 경유(§3.1).
> 5차 개정(P1 서버 구현): **수신자 전화번호는 선택 입력**(§4.1) — 미보유 업장·워크인 현실 반영,
> 미입력 시 링크 열람만(자동 연결 없음). v1 은 자동 연결이 유일한 claim 경로 — 토큰 claim·업체
> 승인 폴백은 앱 딥링크 도입과 함께 후속(§4.2 의 72h 토큰 창은 그때 실장).
> 범위: 업종별 기능 모듈 권한(`business_licenses`), 설치 전 가치 전달용 공유 뷰어(공개 웹 조각),
> 업체 도구 3종(미용 전후 사진 → 위탁 알림장 → 분양 스타터), 법적 정비(대가성 후기 표시·영업자
> 게시 차단), 파일럿 퍼널 계측. 성장 전략의 실행 설계이며 마케팅 캠페인 자체는 다루지 않는다.
Expand Down Expand Up @@ -222,10 +225,15 @@ create table app.care_reports (
);
```

- **발행 플로우(미용)**: 업체 모드 → 전후 사진 촬영/선택 → 아이 이름 + **고객 전화번호 입력**
(예약 때문에 어차피 보유) → 발행 → `share_links` 토큰 생성 → 공유 시트(카톡·문자는
**업체 폰에서** 전송). RPC 첫 줄 `has_license('grooming')`. 전화번호는 서버에서 HMAC 처리
후 즉시 버린다(평문 미저장·미로깅).
- **발행 플로우(미용)**: 업체 모드 → 전후 사진 촬영/선택 → 아이 이름 + **고객 전화번호(선택
입력)** → 발행 → `share_links` 토큰 생성 → 공유 시트(카톡·문자는 **업체 폰에서** 전송).
RPC 첫 줄 `has_license('grooming')`. 전화번호는 서버에서 HMAC 처리 후 즉시 버린다
(평문 미저장·미로깅).
- **전화번호는 선택 입력이다(5차 개정)** — 모든 업장이 손님 번호를 보유한다는 보장이 없고
(워크인 등), 필수로 강제하면 사장님이 발행 자체를 포기해 1순위 지표(업체 재사용률)를
갉아먹는다. 미입력 발행은 링크 열람만으로 가치를 전달하고(자동 연결 없음), UI 는 "번호를
넣으면 손님 가입 시 자동으로 연결돼요"로 유도만 한다. 입력했는데 형식이 틀리면 자동 연결이
조용히 죽지 않도록 **명시 거부**(invalid_phone).
- **수령 플로우**: 보호자는 링크로 즉시 열람(설치 불요) → "앱에서 우리 아이 기록 모아보기"
버튼 → 설치·가입.
- 사진 버킷은 비공개 + signed URL(§3). 업체 모드 사진과 개인 사진의 경로 분리는 0026 관례를 따른다.
Expand Down
34 changes: 33 additions & 1 deletion docs/supabase-db.md
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,37 @@ refresh 토큰 저장소 (설계: `docs/refresh-token-flow-design.md`). 원문

- 인덱스: `funnel_events_token_idx` (token, event).

### app.care_config

케어 리포트 전화번호 HMAC 키 싱글턴 (0028 §4.2). RLS 정책 없음 = definer 전용.

| 컬럼 | 타입 | Null | 기본값 | 설명 |
|---|---|---|---|---|
| id | boolean | NO | `true` | PK + CHECK — 싱글턴 |
| hmac_key | text | NO | `gen_random_bytes(32)` hex | 수신자 번호 키드 해시용(무염 해시는 번호 전수대입에 취약) |
| key_version | smallint | NO | `1` | 키 유출 시 무마이그레이션 로테이션용 선점(평시 단일 키) |

### app.care_reports

업체→보호자 케어 리포트 (0028 §4 — P1 미용 전후 사진, P2 알림장 공용 원형).

| 컬럼 | 타입 | Null | 기본값 | 설명 |
|---|---|---|---|---|
| id | uuid | NO | gen_random_uuid() | PK |
| business_id | uuid | NO | | 발행 업체 → users(id) CASCADE |
| kind | varchar(12) | NO | | `grooming`(v1) \| `boarding`(P2) — CHECK |
| pet_label | varchar(50) | NO | | 업체가 입력한 아이 이름(미가입 보호자 전제) |
| photos | jsonb | NO | `'[]'` | 사진 URL 배열(1~4장, 미용은 전/후 2장 기본) |
| body | jsonb | NO | `'{}'` | kind 별 확장(boarding 식사·배변 등, P2) |
| note | text | YES | | 한 줄 메모 |
| recipient_phone_hmac | bytea | YES | | 수신자 번호 키드 해시 — **선택 입력**, claim 성사 시 즉시 null 파기, 미연결분은 링크 만료 후 크론 파기 |
| recipient_key_version | smallint | NO | `1` | 해시 시점 키 버전 |
| claimed_by / claimed_at | | YES | | 연결 보호자·시각(첫 claim 후 잠금) |
| created_at | timestamptz | NO | now() | |

- 인덱스: business(발행 목록)·phone(미연결 부분)·claimed(받은 목록).
- RPC: `create_care_report(p_pet_label, p_photos, p_note?, p_recipient_phone?)` [SD, `has_license('grooming')` 게이트] — share_links(kind `care_report`, 30일) 토큰 생성 + funnel `report_issued`. `my_care_reports()`(발행 목록, 수령자 닉네임 표시), `claim_care_reports()`(인증 번호 HMAC 대조 자동 연결 — 가입/앱 시작 시 호출, 알림 `system_notice` + funnel `claim`, hmac 파기), `my_received_care_reports()`(보호자 받은 목록).

### pg_cron 스케줄 잡

| 잡 이름 | 스케줄 | 동작 |
Expand All @@ -1079,6 +1110,7 @@ refresh 토큰 저장소 (설계: `docs/refresh-token-flow-design.md`). 원문
| `push-sweep` | `* * * * *` (매분) | `push_status='pending'` 알림이 존재할 때만 `app.push_config`의 URL 로 `net.http_post` — 재시도/누락 보완 스윕 (§8 트리거의 즉시 발사와 이중화) |
| `withdrawn-users-purge` | `43 3 * * *` (매일 03:43) | `delete from app.withdrawn_users where withdrawn_at < now() - interval '30 days'` — 탈퇴자 격리 30일 후 삭제 |
| `funnel-events-retention` | `53 3 * * *` (매일 03:53) | `delete from app.funnel_events where created_at < now() - interval '1 year'` — 퍼널 원시 이벤트 보존 1년 (0028 §7) |
| `care-report-hmac-purge` | `58 3 * * *` (매일 03:58) | 링크 만료(30일) 지난 미연결 케어 리포트의 `recipient_phone_hmac` 파기 (0028 §4.2) |
| `retention-purge` | `23 3 * * *` (매일 03:23) | `app.cleanup_retention()` — 전화 인증코드 1일 / 위치 인증 이력·사진 인증 로그 6개월 / **삭제·탈퇴자 게시글 실좌표 스크럽** / **post_views(ip_hash)·app.auth_logs(접속 로그) 3개월** 파기 / **소프트 삭제된 채팅 메시지 30일 유예 후 하드 삭제**(신고 대응 기간 확보 후 파기). photo_verifications 는 pets·posts FK 참조 때문에 미참조 행만 삭제·참조 행은 촬영 좌표(shot_*)만 스크럽 (→ §13 `20260711130000`·`20260711140000`) |

---
Expand Down Expand Up @@ -1448,7 +1480,7 @@ Refresh token 회전(재사용 감지 + 유실 복구 포함). 반환 `result`

### 7.9.1. 공유 뷰어 RPC (모두 [SD][svc] — `share-view` Edge Function 전용, anon/authenticated EXECUTE 없음)

- `share_view_load(p_token) → jsonb` — 링크 검증(`not_found`/`expired`/`ok`) + kind 별 본문 조회 + 계측(view_count 원자 증가, funnel `share_view`)을 단일 왕복으로. `facility_preview` 는 시설 요약 + **인증 업체 대표 사진·영업시간**(형제 시설 범위 승인 업체 lateral, 콜드스타트 완화) + 최근 후기 3건(visible 만, `has_incentive`·사진 최대 2장 포함). app 스키마가 PostgREST 미노출이라 Edge Function 도 이 RPC 를 경유한다.
- `share_view_load(p_token) → jsonb` — 링크 검증(`not_found`/`expired`/`ok`) + kind 별 본문 조회 + 계측(view_count 원자 증가, funnel `share_view`)을 단일 왕복으로. `facility_preview` 는 시설 요약 + **인증 업체 대표 사진·영업시간**(형제 시설 범위 승인 업체 lateral, 콜드스타트 완화) + 최근 후기 3건(visible 만, `has_incentive`·사진 최대 2장 포함). `care_report` 는 리포트(pet_label·photos·note·업체 상호) 반환. app 스키마가 PostgREST 미노출이라 Edge Function 도 이 RPC 를 경유한다.
- `share_view_click(p_token) → boolean` — 유효 링크일 때만 funnel `store_click` 기록(스토어 302 직전 호출).

### 7.10. 기타
Expand Down
41 changes: 40 additions & 1 deletion supabase/functions/share-view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,47 @@ Deno.serve(async (req) => {
if (data.status !== "ok") {
return noticePage("링크를 찾을 수 없어요", "회수되었거나 존재하지 않는 링크예요.", 404);
}
// ── 케어 리포트(P1 미용 전후 사진) — 사진이 주인공, 설치 CTA 는 "내 기록 받기" ──
if (data.kind === "care_report") {
const rp = data.report ?? {};
const petLabel = String(rp.pet_label ?? "우리 아이");
const bizName = rp.business_name ? String(rp.business_name) : null;
const note = rp.note ? String(rp.note) : null;
const photos: string[] = Array.isArray(rp.photos) ? rp.photos.map(String) : [];
const d = new Date(String(rp.created_at ?? ""));
const dateStr = isNaN(d.getTime())
? ""
: `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${
String(d.getDate()).padStart(2, "0")
}`;
const photoHtml = photos.length === 0 ? "" : `<div class="rgrid" style="margin-top:12px">${
photos.map((u, i) =>
`<div class="rtile hasimg" ${
photos.length === 1 ? 'style="grid-column:1/-1;aspect-ratio:4/3"' : ""
}>
<img src="${esc(u)}" alt="" loading="lazy">
${
photos.length === 2
? `<div class="badges"><span class="badge">${i === 0 ? "전" : "후"}</span></div>`
: ""
}
</div>`
).join("")
}</div>`;
const inner = `
<div class="titlebar" style="margin-top:4px"><b>${esc(petLabel)}의 미용 기록</b></div>
<div class="info-card">
${bizName ? `<div class="verified">✓ ${esc(bizName)}에서 보냈어요</div>` : ""}
${dateStr ? `<div class="irow"><span class="ic">🗓</span><span>${dateStr}</span></div>` : ""}
${note ? `<div class="irow"><span class="ic">💬</span><span>${esc(note)}</span></div>` : ""}
</div>
${photoHtml}
<a class="cta" href="?t=${token}&amp;go=store">PawMate 앱에서 ${esc(petLabel)} 기록 모아보기</a>
<a class="cta sub" href="?t=${token}&amp;go=store">가입하면 다음 기록은 자동으로 도착해요</a>`;
return html(page(`${petLabel}의 미용 기록`, `${bizName ?? "PawMate"} · 케어 리포트`, inner));
}

if (data.kind !== "facility_preview") {
// care_report 등 P1 kind — 링크는 유효하나 뷰어 본문은 P1 에서.
return noticePage("준비 중인 콘텐츠예요", "앱에서 곧 만나볼 수 있어요.", 200);
}

Expand Down
Loading
Loading