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
21 changes: 19 additions & 2 deletions docs/supabase-db.md
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,23 @@ refresh 토큰 저장소 (설계: `docs/refresh-token-flow-design.md`). 원문
| created_at | timestamptz | NO | now() | |

- 인덱스: business(발행 목록)·phone(미연결 부분)·claimed(받은 목록).
### app.care_threads

위탁 알림장 스레드 (0028 §4.4) — 반려동물×업체 **상시 1개**, '위탁 건' 엔티티 없음.

| 컬럼 | 타입 | Null | 기본값 | 설명 |
|---|---|---|---|---|
| id | uuid | NO | gen_random_uuid() | PK |
| business_id | uuid | NO | | 업체 → users(id) CASCADE |
| pet_label | varchar(50) | NO | | 아이 이름(유니크 아님 — 동명 두 아이 실존, 스레드 선택은 업체 UI) |
| recipient_phone_hmac / recipient_key_version | | YES | | 스레드 단위 수신자(선택). claim 성사 시 파기, 미연결분은 마지막 발행 30일 후 크론 파기 |
| claimed_by / claimed_at | | YES | | 연결 보호자 — 연결되면 이후 발행이 즉시 보이고 도착 알림 발송 |
| last_report_at | timestamptz | YES | | 발행 시 갱신 — **보관은 파생값**(`boarding_archive_days`, care_config 기본 7) |

- RPC: `create_care_thread(p_pet_label, p_recipient_phone?)` [SD, `has_license('boarding')`], `create_boarding_report(p_thread, p_photos?, p_body?, p_note?)` [SD — 빈 발행 거부, 단건 공유 링크(care_report 30일)+funnel, 연결 보호자 알림], `my_care_threads()` (archived 파생·최근 사진), `care_thread_reports(p_thread, ...)` (업체 소유 또는 연결 보호자만).
- `claim_care_reports()` 는 단건 리포트에 더해 **스레드 단위 자동 연결**(기존 기록 포함) 수행.
- `my_received_care_reports()` 반환에 `body`·`thread_id` 추가(반환형 변경 재생성).

- 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 @@ -1110,7 +1127,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) |
| `care-report-hmac-purge` | `58 3 * * *` (매일 03:58) | 링크 만료(30일) 지난 미연결 케어 리포트 + 마지막 발행 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 @@ -1480,7 +1497,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장 포함). `care_report` 는 리포트(pet_label·photos·note·업체 상호) 반환. 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·kind·photos·body·note·업체 상호) 반환 — 뷰어가 미용/돌봄 제목·알림장 필드(식사·배변 등)를 분기 렌더. app 스키마가 PostgREST 미노출이라 Edge Function 도 이 RPC 를 경유한다.
- `share_view_click(p_token) → boolean` — 유효 링크일 때만 funnel `store_click` 기록(스토어 302 직전 호출).

### 7.10. 기타
Expand Down
2 changes: 1 addition & 1 deletion scripts/run_db_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ for f in supabase/tests/*_test.sql; do
continue
fi
echo "$out"
if grep -qE '^[[:space:]]*not ok' <<<"$out"; then
if grep -qE '^[[:space:]]*(not ok|# Looks like)' <<<"$out"; then
echo "-- 단언 실패: $f"
fail=1
fi
Expand Down
28 changes: 23 additions & 5 deletions supabase/functions/share-view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//
// 설치 전 가치 먼저(0028 원칙 2): 매장 미리보기(facility_preview)를 설치·가입
// 없이 보여주고, 설치 유도 버튼만 둔다. 후기 강요·가입 강제 없음.
// kind='care_report' 는 P1(미용 전후 사진)에서 열린다 — 지금은 안내만.
// kind='care_report'(P1 미용 전후 사진 · P2 알림장)는 사진 중심 + '내 기록 받기' CTA.
//
// 디자인 = 앱 업체 프로필(user_profile_screen 업체 얼굴) 미러:
// 애플뮤직 스타일 헤더 카드(사진 풀블리드 + 점진 블러 + 상호·통계 2칸) →
Expand Down Expand Up @@ -202,12 +202,29 @@ Deno.serve(async (req) => {
if (data.status !== "ok") {
return noticePage("링크를 찾을 수 없어요", "회수되었거나 존재하지 않는 링크예요.", 404);
}
// ── 케어 리포트(P1 미용 전후 사진) — 사진이 주인공, 설치 CTA 는 "내 기록 받기" ──
// ── 케어 리포트(P1 미용 전후 사진 · P2 알림장) — 사진이 주인공 ──
if (data.kind === "care_report") {
const rp = data.report ?? {};
const petLabel = String(rp.pet_label ?? "우리 아이");
const isBoarding = String(rp.kind ?? "grooming") === "boarding";
const kindLabel = isBoarding ? "돌봄 기록" : "미용 기록";
const bizName = rp.business_name ? String(rp.business_name) : null;
const note = rp.note ? String(rp.note) : null;
// 알림장 구조 필드(0028 §4.4) — 알려진 키는 한글 라벨, 그 외는 키 그대로.
const BODY_LABELS: Record<string, string> = {
meal: "🍚 식사",
potty: "🚽 배변",
mood: "😊 컨디션",
walk: "🐾 산책",
};
const body = (rp.body ?? {}) as Record<string, unknown>;
const bodyRows = Object.entries(body)
.filter(([, v]) => v != null && String(v).trim() !== "")
.map(([k, v]) =>
`<div class="irow"><span class="ic"></span><span><b>${
esc(BODY_LABELS[k] ?? k)
}</b> · ${esc(String(v))}</span></div>`
).join("");
const photos: string[] = Array.isArray(rp.photos) ? rp.photos.map(String) : [];
const d = new Date(String(rp.created_at ?? ""));
const dateStr = isNaN(d.getTime())
Expand All @@ -222,24 +239,25 @@ Deno.serve(async (req) => {
}>
<img src="${esc(u)}" alt="" loading="lazy">
${
photos.length === 2
!isBoarding && 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="titlebar" style="margin-top:4px"><b>${esc(petLabel)}의 ${kindLabel}</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>` : ""}
${bodyRows}
${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));
return html(page(`${petLabel}의 ${kindLabel}`, `${bizName ?? "PawMate"} · 케어 리포트`, inner));
}

if (data.kind !== "facility_preview") {
Expand Down
Loading
Loading