From 514dbdbd0d044eb5a9d51526900d92e1b109015d Mon Sep 17 00:00:00 2001 From: seizeh Date: Wed, 22 Jul 2026 22:53:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=200028=20P1=20=EC=BC=80=EC=96=B4=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=84=9C=EB=B2=84=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=C2=B7=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?HMAC=20=EC=9E=90=EB=8F=99=20=EC=97=B0=EA=B2=B0=C2=B7=EB=B7=B0?= =?UTF-8?q?=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '업체가 보호자에게 보내는 순간의 독점' 첫 도구(미용 전후 사진) 서버 전체: - app.care_reports(P2 알림장 공용 원형) + app.care_config(HMAC 키 싱글턴, key_version 선점) + 인덱스 3종 - create_care_report: has_license('grooming') 게이트, 사진 1~4장, 전화번호 **선택 입력**(형식 오류는 invalid_phone 명시 거부), share_links 30일 토큰 + funnel report_issued - claim_care_reports: 인증 번호 HMAC 대조 자동 연결(경로 무관) — 연결 시 hmac 즉시 파기·도착 알림(system_notice)·funnel claim, 자기 발행 자기 연결 방지, 첫 claim 후 잠금 - my_care_reports(수령자 표시)/my_received_care_reports - share-view v8: care_report 렌더(전/후 배지 사진 그리드 + '기록 모아보기' CTA), 미연결 hmac 30일 크론 파기(care-report-hmac-purge 03:58) - 0028 5차 개정(전화번호 선택 입력·v1 claim 범위), pgTAP t12 9케이스 (t11) — 전 스위트 운영 DB 통과, 운영 적용·뷰어 렌더 검증 완료 Co-Authored-By: Claude Fable 5 --- 0028.md | 16 +- docs/supabase-db.md | 34 +- supabase/functions/share-view/index.ts | 41 ++- .../20260723010000_care_reports_p1.sql | 312 ++++++++++++++++++ supabase/schema/schema.sql | 305 +++++++++++++++++ supabase/tests/t11_care_reports_test.sql | 79 +++++ 6 files changed, 781 insertions(+), 6 deletions(-) create mode 100644 supabase/migrations/20260723010000_care_reports_p1.sql create mode 100644 supabase/tests/t11_care_reports_test.sql diff --git a/0028.md b/0028.md index e01c0fe..2a4d671 100644 --- a/0028.md +++ b/0028.md @@ -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종(미용 전후 사진 → 위탁 알림장 → 분양 스타터), 법적 정비(대가성 후기 표시·영업자 > 게시 차단), 파일럿 퍼널 계측. 성장 전략의 실행 설계이며 마케팅 캠페인 자체는 다루지 않는다. @@ -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 관례를 따른다. diff --git a/docs/supabase-db.md b/docs/supabase-db.md index f82c9d8..357f665 100644 --- a/docs/supabase-db.md +++ b/docs/supabase-db.md @@ -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 스케줄 잡 | 잡 이름 | 스케줄 | 동작 | @@ -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`) | --- @@ -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. 기타 diff --git a/supabase/functions/share-view/index.ts b/supabase/functions/share-view/index.ts index 11b2016..4a96009 100644 --- a/supabase/functions/share-view/index.ts +++ b/supabase/functions/share-view/index.ts @@ -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 ? "" : `
${ + photos.map((u, i) => + `
+ + ${ + photos.length === 2 + ? `
${i === 0 ? "전" : "후"}
` + : "" + } +
` + ).join("") + }
`; + const inner = ` +
${esc(petLabel)}의 미용 기록
+
+ ${bizName ? `
✓ ${esc(bizName)}에서 보냈어요
` : ""} + ${dateStr ? `
🗓${dateStr}
` : ""} + ${note ? `
💬${esc(note)}
` : ""} +
+ ${photoHtml} + PawMate 앱에서 ${esc(petLabel)} 기록 모아보기 + 가입하면 다음 기록은 자동으로 도착해요`; + return html(page(`${petLabel}의 미용 기록`, `${bizName ?? "PawMate"} · 케어 리포트`, inner)); + } + if (data.kind !== "facility_preview") { - // care_report 등 P1 kind — 링크는 유효하나 뷰어 본문은 P1 에서. return noticePage("준비 중인 콘텐츠예요", "앱에서 곧 만나볼 수 있어요.", 200); } diff --git a/supabase/migrations/20260723010000_care_reports_p1.sql b/supabase/migrations/20260723010000_care_reports_p1.sql new file mode 100644 index 0000000..ccb59a2 --- /dev/null +++ b/supabase/migrations/20260723010000_care_reports_p1.sql @@ -0,0 +1,312 @@ +-- ============================================================================ +-- 0028 P1 — 케어 리포트(미용 전후 사진) 서버: care_reports · 발행/목록/claim RPC +-- +-- "업체가 보호자에게 보내야 하는 순간을 앱이 독점한다"의 첫 도구. 미용 완료 +-- 사진을 링크로 전달하고, 그 링크가 곧 가입 유도가 된다(0028 §4). +-- +-- claim 설계(0028 §4.2 · 3차 개정): +-- · 1차 = 전화번호 HMAC 대조 자동 연결 — 가입/로그인 시 claim_care_reports() +-- 호출로 대기 리포트가 자동 연결된다(경로 무관: QR·링크·스토어 검색). +-- · 전화번호는 **선택 입력**(§4.1 4차 개정) — 없으면 링크 열람만으로 가치 전달, +-- 연결은 안 됨(토큰 claim·업체 승인 폴백은 앱 딥링크와 함께 후속). +-- · 키드 HMAC(무염 해시는 번호 전수대입에 취약), 키는 app.care_config 싱글턴. +-- claim 성사 시 hmac 즉시 파기, 미연결분은 링크 만료(30일)와 함께 배치 파기. +-- · kind 는 v1 grooming 고정 — boarding(알림장)은 P2 에서 같은 원형을 반복형으로. +-- ============================================================================ + +-- (1) HMAC 키 싱글턴 — push_config 패턴. key_version 은 유출 시 무마이그레이션 +-- 로테이션용 선점(평시 단일 키, 0028 §10-3). +create table app.care_config ( + id boolean primary key default true, + hmac_key text not null default encode(extensions.gen_random_bytes(32), 'hex'), + key_version smallint not null default 1, + constraint care_config_singleton check (id) +); +alter table app.care_config enable row level security; -- 정책 없음 = definer 전용 +insert into app.care_config (id) values (true) on conflict (id) do nothing; + +-- (2) 케어 리포트 (0028 §4.1) +create table app.care_reports ( + id uuid primary key default gen_random_uuid(), + business_id uuid not null references public.users(id) on delete cascade, + kind varchar(12) not null check (kind in ('grooming', 'boarding')), + pet_label varchar(50) not null, -- 업체가 입력하는 아이 이름(미가입 보호자 전제) + photos jsonb not null default '[]', -- [url, ...] — 미용은 전/후 2장 기본, 최대 4 + body jsonb not null default '{}', -- kind 별 확장 필드(boarding: 식사·배변 등, P2) + note text, + recipient_phone_hmac bytea, -- 수신 보호자 번호 키드 해시. claim 성사 시 null 파기 + recipient_key_version smallint not null default 1, + claimed_by uuid references public.users(id), + claimed_at timestamptz, -- 첫 claim 후 잠금(재 claim 불가) + created_at timestamptz not null default now() +); +create index care_reports_business_idx on app.care_reports (business_id, created_at desc); +create index care_reports_phone_idx on app.care_reports (recipient_phone_hmac) + where claimed_by is null and recipient_phone_hmac is not null; +create index care_reports_claimed_idx on app.care_reports (claimed_by, created_at desc); +comment on table app.care_reports is + '업체→보호자 케어 리포트(0028 §4 — 미용 전후 사진·P2 알림장 공용 원형).'; + +-- 전화번호 정규화 + HMAC — 숫자만 남겨 10~11자리 검증(형식 불일치는 null 반환). +create or replace function app.phone_hmac(p_phone text) +returns bytea +language plpgsql stable +set search_path to '' +as $$ +declare + v_digits text := regexp_replace(coalesce(p_phone, ''), '\D', '', 'g'); + v_key text; +begin + if length(v_digits) not between 10 and 11 then return null; end if; + select hmac_key into v_key from app.care_config; + return extensions.hmac(v_digits, v_key, 'sha256'); +end; +$$; + +-- (3) 발행 — has_license('grooming') 게이트(0028 §1.3). 반환: 리포트 id + 공유 토큰. +create or replace function public.create_care_report( + p_pet_label text, + p_photos jsonb, + p_note text default null, + p_recipient_phone text default null +) returns jsonb +language plpgsql +security definer +set search_path to '' +as $function$ +declare + v_uid uuid := app.uid(); + v_label text := nullif(btrim(coalesce(p_pet_label, '')), ''); + v_hmac bytea; + v_report uuid; + v_token varchar(32); + v_exp timestamptz; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not app.has_license('grooming') then + raise exception 'license_required' using errcode = 'P0001'; + end if; + if v_label is null or length(v_label) > 50 then + raise exception 'invalid_pet_label' using errcode = 'P0001'; + end if; + if jsonb_typeof(p_photos) is distinct from 'array' + or jsonb_array_length(p_photos) not between 1 and 4 then + raise exception 'invalid_photos' using errcode = 'P0001'; + end if; + -- 전화번호는 선택 — 입력했는데 형식이 틀리면 자동 연결이 조용히 죽으므로 명시 거부. + if p_recipient_phone is not null and btrim(p_recipient_phone) <> '' then + v_hmac := app.phone_hmac(p_recipient_phone); + if v_hmac is null then + raise exception 'invalid_phone' using errcode = 'P0001'; + end if; + end if; + + insert into app.care_reports + (business_id, kind, pet_label, photos, note, + recipient_phone_hmac, recipient_key_version) + values + (v_uid, 'grooming', v_label, p_photos, nullif(btrim(coalesce(p_note, '')), ''), + v_hmac, (select key_version from app.care_config)) + returning id into v_report; + + v_token := encode(extensions.gen_random_bytes(16), 'hex'); + v_exp := now() + interval '30 days'; + insert into app.share_links (token, kind, ref_id, created_by, expires_at) + values (v_token, 'care_report', v_report, v_uid, v_exp); + + insert into app.funnel_events (event, token, user_id) + values ('report_issued', v_token, v_uid); + + return jsonb_build_object('report_id', v_report, 'token', v_token, 'expires_at', v_exp); +end; +$function$; +revoke all on function public.create_care_report(text, jsonb, text, text) from public; +grant execute on function public.create_care_report(text, jsonb, text, text) to authenticated; + +-- (4) 내 발행 목록 — 수령자 연결 표시(오연결을 업체가 발견, 0028 §4.2-3). +create or replace function public.my_care_reports( + p_limit integer default 30, + p_offset integer default 0 +) returns table ( + id uuid, pet_label text, photos jsonb, note text, created_at timestamptz, + claimed_nickname text, token text, expires_at timestamptz, view_count integer +) +language sql stable security definer +set search_path to '' +as $$ + select r.id, r.pet_label::text, r.photos, r.note, r.created_at, + pr.nickname::text, l.token::text, l.expires_at, l.view_count + from app.care_reports r + left join app.share_links l on l.kind = 'care_report' and l.ref_id = r.id + left join public.public_profiles pr on pr.id = r.claimed_by + where r.business_id = app.uid() + order by r.created_at desc + limit least(coalesce(p_limit, 30), 100) offset coalesce(p_offset, 0); +$$; +revoke all on function public.my_care_reports(integer, integer) from public; +grant execute on function public.my_care_reports(integer, integer) to authenticated; + +-- (5) 자동 연결 — 가입 직후·앱 시작 시 호출. 내 인증 번호의 HMAC 와 일치하는 +-- 미연결 리포트를 연결하고 hmac 즉시 파기, 알림 발송. 연결 건수 반환. +create or replace function public.claim_care_reports() +returns integer +language plpgsql +security definer +set search_path to '' +as $function$ +declare + v_uid uuid := app.uid(); + v_hmac bytea; + v_cnt integer := 0; + r record; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + select app.phone_hmac(u.phone) into v_hmac from public.users u where u.id = v_uid; + if v_hmac is null then return 0; end if; + + for r in + update app.care_reports cr + set claimed_by = v_uid, claimed_at = now(), recipient_phone_hmac = null + where cr.recipient_phone_hmac = v_hmac + and cr.claimed_by is null + and cr.business_id <> v_uid -- 자기 발행분 자기 연결 방지 + returning cr.id, cr.pet_label + loop + v_cnt := v_cnt + 1; + insert into public.notifications (user_id, notification_type, is_system, title, body) + values (v_uid, 'system_notice', true, + r.pet_label || ' 케어 기록이 도착했어요', + '업체가 보내준 ' || r.pet_label || ' 사진을 앱에서 확인해 보세요.'); + insert into app.funnel_events (event, token, user_id) + select 'claim', l.token, v_uid + from app.share_links l + where l.kind = 'care_report' and l.ref_id = r.id; + end loop; + return v_cnt; +end; +$function$; +revoke all on function public.claim_care_reports() from public; +grant execute on function public.claim_care_reports() to authenticated; + +-- (6) 내가 받은 리포트 목록 — 보호자 화면용. +create or replace function public.my_received_care_reports( + p_limit integer default 30, + p_offset integer default 0 +) returns table ( + id uuid, kind text, pet_label text, photos jsonb, note text, + created_at timestamptz, business_name text +) +language sql stable security definer +set search_path to '' +as $$ + select r.id, r.kind::text, r.pet_label::text, r.photos, r.note, r.created_at, + coalesce(b.storefront_name, b.business_name)::text + from app.care_reports r + left join public.business_profiles b on b.user_id = r.business_id + where r.claimed_by = app.uid() + order by r.created_at desc + limit least(coalesce(p_limit, 30), 100) offset coalesce(p_offset, 0); +$$; +revoke all on function public.my_received_care_reports(integer, integer) from public; +grant execute on function public.my_received_care_reports(integer, integer) to authenticated; + +-- (7) 공유 뷰어 — care_report 분기 실장(기존 '준비 중' 스텁 대체). +-- 미연결분 hmac 파기는 링크 만료와 동기(0028 3차 개정): 만료 리포트의 hmac +-- 을 funnel 크론이 도는 시간대에 함께 정리하도록 여기서 처리하지 않고, +-- 별도 크론에 위임(아래 (8)). +create or replace function public.share_view_load(p_token varchar) +returns jsonb +language plpgsql +security definer +set search_path to 'public' +as $function$ +declare + v_link app.share_links%rowtype; + v_out jsonb; +begin + select * into v_link from app.share_links where token = p_token; + if not found or v_link.revoked_at is not null then + return jsonb_build_object('status', 'not_found'); + end if; + if v_link.expires_at < now() then + return jsonb_build_object('status', 'expired'); + end if; + + update app.share_links set view_count = view_count + 1 where token = p_token; + insert into app.funnel_events (event, token) values ('share_view', p_token); + + if v_link.kind = 'facility_preview' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'facility', jsonb_build_object( + 'name', f.name, 'category', f.category, 'address', f.address, + 'phone', f.phone, 'is_open', f.is_open, + 'avg_rating', f.avg_rating, 'review_count', f.review_count, + 'photo_url', bp.photo_url, + 'photo_align_y', coalesce(bp.photo_align_y, 0), + 'business_hours', bp.business_hours, + 'owner_verified', coalesce(bp.verified, false)), + 'reviews', coalesce(( + select jsonb_agg(jsonb_build_object( + 'rating', r.rating, 'content', r.content, + 'has_incentive', r.has_incentive, + 'photo_urls', r.photos) + order by r.has_photo desc, r.created_at desc) + from (select rating, content, has_incentive, created_at, + coalesce(array_length(photo_urls, 1), 0) > 0 as has_photo, + (select coalesce(jsonb_agg(u), '[]'::jsonb) + from unnest(photo_urls[1:2]) u) as photos + from public.facility_reviews + where facility_id = f.id and visibility_status = 'visible' + order by coalesce(array_length(photo_urls, 1), 0) > 0 desc, + created_at desc + limit 3) r), '[]'::jsonb)) + into v_out + from public.facilities f + left join lateral ( + select true as verified, b.photo_url, b.photo_align_y, b.business_hours + from public.business_profiles b + where b.status = 'approved' + and b.matched_facility_id = any(public.facility_sibling_ids(f.id)) + order by b.reviewed_at nulls last + limit 1 + ) bp on true + where f.id = v_link.ref_id; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + + if v_link.kind = 'care_report' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'report', jsonb_build_object( + 'pet_label', r.pet_label, 'photos', r.photos, 'note', r.note, + 'kind', r.kind, 'created_at', r.created_at, + 'business_name', coalesce(b.storefront_name, b.business_name))) + into v_out + from app.care_reports r + left join public.business_profiles b on b.user_id = r.business_id + where r.id = v_link.ref_id; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + + return jsonb_build_object('status', 'ok', 'kind', v_link.kind); +end; +$function$; + +-- (8) 미연결 리포트의 hmac 파기 — 링크 만료(30일) 경과분(0028 §4.2-4). +-- funnel 보존 크론과 같은 시간대(03:53대) 뒤인 03:58 에 별도 잡. +do $$ begin + if exists (select 1 from cron.job where jobname = 'care-report-hmac-purge') then + perform cron.unschedule('care-report-hmac-purge'); + end if; +end $$; +select cron.schedule('care-report-hmac-purge', '58 3 * * *', + $$update app.care_reports r set recipient_phone_hmac = null + where r.recipient_phone_hmac is not null and r.claimed_by is null + and exists (select 1 from app.share_links l + where l.kind = 'care_report' and l.ref_id = r.id + and l.expires_at < now())$$); diff --git a/supabase/schema/schema.sql b/supabase/schema/schema.sql index c8e76a7..dc3924c 100644 --- a/supabase/schema/schema.sql +++ b/supabase/schema/schema.sql @@ -541,6 +541,25 @@ begin end $$; +-- +-- Name: phone_hmac(text); Type: FUNCTION; Schema: app; Owner: - +-- + +CREATE FUNCTION app.phone_hmac(p_phone text) RETURNS bytea + LANGUAGE plpgsql STABLE + SET search_path TO '' + AS $$ +declare + v_digits text := regexp_replace(coalesce(p_phone, ''), '\D', '', 'g'); + v_key text; +begin + if length(v_digits) not between 10 and 11 then return null; end if; + select hmac_key into v_key from app.care_config; + return extensions.hmac(v_digits, v_key, 'sha256'); +end; +$$; + + -- -- Name: posts_set_authored_as(); Type: FUNCTION; Schema: app; Owner: - -- @@ -3688,6 +3707,106 @@ CREATE FUNCTION public.check_username_available(p_username text) RETURNS boolean $$; +-- +-- Name: claim_care_reports(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.claim_care_reports() RETURNS integer + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +declare + v_uid uuid := app.uid(); + v_hmac bytea; + v_cnt integer := 0; + r record; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + select app.phone_hmac(u.phone) into v_hmac from public.users u where u.id = v_uid; + if v_hmac is null then return 0; end if; + + for r in + update app.care_reports cr + set claimed_by = v_uid, claimed_at = now(), recipient_phone_hmac = null + where cr.recipient_phone_hmac = v_hmac + and cr.claimed_by is null + and cr.business_id <> v_uid + returning cr.id, cr.pet_label + loop + v_cnt := v_cnt + 1; + insert into public.notifications (user_id, notification_type, is_system, title, body) + values (v_uid, 'system_notice', true, + r.pet_label || ' 케어 기록이 도착했어요', + '업체가 보내준 ' || r.pet_label || ' 사진을 앱에서 확인해 보세요.'); + insert into app.funnel_events (event, token, user_id) + select 'claim', l.token, v_uid + from app.share_links l + where l.kind = 'care_report' and l.ref_id = r.id; + end loop; + return v_cnt; +end; +$$; + + +-- +-- Name: create_care_report(text, jsonb, text, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.create_care_report(p_pet_label text, p_photos jsonb, p_note text DEFAULT NULL::text, p_recipient_phone text DEFAULT NULL::text) RETURNS jsonb + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +declare + v_uid uuid := app.uid(); + v_label text := nullif(btrim(coalesce(p_pet_label, '')), ''); + v_hmac bytea; + v_report uuid; + v_token varchar(32); + v_exp timestamptz; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not app.has_license('grooming') then + raise exception 'license_required' using errcode = 'P0001'; + end if; + if v_label is null or length(v_label) > 50 then + raise exception 'invalid_pet_label' using errcode = 'P0001'; + end if; + if jsonb_typeof(p_photos) is distinct from 'array' + or jsonb_array_length(p_photos) not between 1 and 4 then + raise exception 'invalid_photos' using errcode = 'P0001'; + end if; + if p_recipient_phone is not null and btrim(p_recipient_phone) <> '' then + v_hmac := app.phone_hmac(p_recipient_phone); + if v_hmac is null then + raise exception 'invalid_phone' using errcode = 'P0001'; + end if; + end if; + + insert into app.care_reports + (business_id, kind, pet_label, photos, note, + recipient_phone_hmac, recipient_key_version) + values + (v_uid, 'grooming', v_label, p_photos, nullif(btrim(coalesce(p_note, '')), ''), + v_hmac, (select key_version from app.care_config)) + returning id into v_report; + + v_token := encode(extensions.gen_random_bytes(16), 'hex'); + v_exp := now() + interval '30 days'; + insert into app.share_links (token, kind, ref_id, created_by, expires_at) + values (v_token, 'care_report', v_report, v_uid, v_exp); + + insert into app.funnel_events (event, token, user_id) + values ('report_issued', v_token, v_uid); + + return jsonb_build_object('report_id', v_report, 'token', v_token, 'expires_at', v_exp); +end; +$$; + + -- -- Name: create_post_verified(character varying, character varying, text, timestamp with time zone, uuid[], text, character varying, integer, uuid, double precision, double precision, character varying); Type: FUNCTION; Schema: public; Owner: - -- @@ -4299,6 +4418,43 @@ CREATE FUNCTION public.my_business_licenses() RETURNS TABLE(id uuid, license_typ $$; +-- +-- Name: my_care_reports(integer, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.my_care_reports(p_limit integer DEFAULT 30, p_offset integer DEFAULT 0) RETURNS TABLE(id uuid, pet_label text, photos jsonb, note text, created_at timestamp with time zone, claimed_nickname text, token text, expires_at timestamp with time zone, view_count integer) + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path TO '' + AS $$ + select r.id, r.pet_label::text, r.photos, r.note, r.created_at, + pr.nickname::text, l.token::text, l.expires_at, l.view_count + from app.care_reports r + left join app.share_links l on l.kind = 'care_report' and l.ref_id = r.id + left join public.public_profiles pr on pr.id = r.claimed_by + where r.business_id = app.uid() + order by r.created_at desc + limit least(coalesce(p_limit, 30), 100) offset coalesce(p_offset, 0); +$$; + + +-- +-- Name: my_received_care_reports(integer, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.my_received_care_reports(p_limit integer DEFAULT 30, p_offset integer DEFAULT 0) RETURNS TABLE(id uuid, kind text, pet_label text, photos jsonb, note text, created_at timestamp with time zone, business_name text) + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path TO '' + AS $$ + select r.id, r.kind::text, r.pet_label::text, r.photos, r.note, r.created_at, + coalesce(b.storefront_name, b.business_name)::text + from app.care_reports r + left join public.business_profiles b on b.user_id = r.business_id + where r.claimed_by = app.uid() + order by r.created_at desc + limit least(coalesce(p_limit, 30), 100) offset coalesce(p_offset, 0); +$$; + + -- -- Name: naver_facility_id(text, text); Type: FUNCTION; Schema: public; Owner: - -- @@ -4972,6 +5128,20 @@ begin return coalesce(v_out, jsonb_build_object('status', 'not_found')); end if; + if v_link.kind = 'care_report' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'report', jsonb_build_object( + 'pet_label', r.pet_label, 'photos', r.photos, 'note', r.note, + 'kind', r.kind, 'created_at', r.created_at, + 'business_name', coalesce(b.storefront_name, b.business_name))) + into v_out + from app.care_reports r + left join public.business_profiles b on b.user_id = r.business_id + where r.id = v_link.ref_id; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + return jsonb_build_object('status', 'ok', 'kind', v_link.kind); end; $$; @@ -5425,6 +5595,46 @@ CREATE TABLE app.business_purge_config ( ); +-- +-- Name: care_config; Type: TABLE; Schema: app; Owner: - +-- + +CREATE TABLE app.care_config ( + id boolean DEFAULT true NOT NULL, + hmac_key text DEFAULT encode(extensions.gen_random_bytes(32), 'hex'::text) NOT NULL, + key_version smallint DEFAULT 1 NOT NULL, + CONSTRAINT care_config_singleton CHECK (id) +); + + +-- +-- Name: care_reports; Type: TABLE; Schema: app; Owner: - +-- + +CREATE TABLE app.care_reports ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + business_id uuid NOT NULL, + kind character varying(12) NOT NULL, + pet_label character varying(50) NOT NULL, + photos jsonb DEFAULT '[]'::jsonb NOT NULL, + body jsonb DEFAULT '{}'::jsonb NOT NULL, + note text, + recipient_phone_hmac bytea, + recipient_key_version smallint DEFAULT 1 NOT NULL, + claimed_by uuid, + claimed_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT care_reports_kind_check CHECK (((kind)::text = ANY ((ARRAY['grooming'::character varying, 'boarding'::character varying])::text[]))) +); + + +-- +-- Name: TABLE care_reports; Type: COMMENT; Schema: app; Owner: - +-- + +COMMENT ON TABLE app.care_reports IS '업체→보호자 케어 리포트(0028 §4 — 미용 전후 사진·P2 알림장 공용 원형).'; + + -- -- Name: funnel_events; Type: TABLE; Schema: app; Owner: - -- @@ -6829,6 +7039,22 @@ ALTER TABLE ONLY app.business_purge_config ADD CONSTRAINT business_purge_config_pkey PRIMARY KEY (id); +-- +-- Name: care_config care_config_pkey; Type: CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_config + ADD CONSTRAINT care_config_pkey PRIMARY KEY (id); + + +-- +-- Name: care_reports care_reports_pkey; Type: CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_reports + ADD CONSTRAINT care_reports_pkey PRIMARY KEY (id); + + -- -- Name: funnel_events funnel_events_pkey; Type: CONSTRAINT; Schema: app; Owner: - -- @@ -7309,6 +7535,27 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +-- +-- Name: care_reports_business_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_reports_business_idx ON app.care_reports USING btree (business_id, created_at DESC); + + +-- +-- Name: care_reports_claimed_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_reports_claimed_idx ON app.care_reports USING btree (claimed_by, created_at DESC); + + +-- +-- Name: care_reports_phone_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_reports_phone_idx ON app.care_reports USING btree (recipient_phone_hmac) WHERE ((claimed_by IS NULL) AND (recipient_phone_hmac IS NOT NULL)); + + -- -- Name: funnel_events_token_idx; Type: INDEX; Schema: app; Owner: - -- @@ -8404,6 +8651,22 @@ ALTER TABLE ONLY app.business_licenses ADD CONSTRAINT business_licenses_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; +-- +-- Name: care_reports care_reports_business_id_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_reports + ADD CONSTRAINT care_reports_business_id_fkey FOREIGN KEY (business_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: care_reports care_reports_claimed_by_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_reports + ADD CONSTRAINT care_reports_claimed_by_fkey FOREIGN KEY (claimed_by) REFERENCES public.users(id); + + -- -- Name: refresh_tokens refresh_tokens_replaced_by_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - -- @@ -8926,6 +9189,12 @@ ALTER TABLE app.business_doc_purge_queue ENABLE ROW LEVEL SECURITY; ALTER TABLE app.business_purge_config ENABLE ROW LEVEL SECURITY; +-- +-- Name: care_config; Type: ROW SECURITY; Schema: app; Owner: - +-- + +ALTER TABLE app.care_config ENABLE ROW LEVEL SECURITY; + -- -- Name: location_usage_logs; Type: ROW SECURITY; Schema: app; Owner: - -- @@ -10140,6 +10409,24 @@ GRANT ALL ON FUNCTION public.check_username_available(p_username text) TO authen GRANT ALL ON FUNCTION public.check_username_available(p_username text) TO service_role; +-- +-- Name: FUNCTION claim_care_reports(); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.claim_care_reports() FROM PUBLIC; +GRANT ALL ON FUNCTION public.claim_care_reports() TO authenticated; +GRANT ALL ON FUNCTION public.claim_care_reports() TO service_role; + + +-- +-- Name: FUNCTION create_care_report(p_pet_label text, p_photos jsonb, p_note text, p_recipient_phone text); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.create_care_report(p_pet_label text, p_photos jsonb, p_note text, p_recipient_phone text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.create_care_report(p_pet_label text, p_photos jsonb, p_note text, p_recipient_phone text) TO authenticated; +GRANT ALL ON FUNCTION public.create_care_report(p_pet_label text, p_photos jsonb, p_note text, p_recipient_phone text) TO service_role; + + -- -- Name: FUNCTION create_post_verified(p_category character varying, p_title character varying, p_content text, p_scheduled_at timestamp with time zone, p_pet_ids uuid[], p_image_url text, p_image_mime character varying, p_image_size integer, p_photo_token uuid, p_actual_lat double precision, p_actual_lng double precision, p_region_code character varying); Type: ACL; Schema: public; Owner: - -- @@ -10306,6 +10593,24 @@ GRANT ALL ON FUNCTION public.my_business_licenses() TO authenticated; GRANT ALL ON FUNCTION public.my_business_licenses() TO service_role; +-- +-- Name: FUNCTION my_care_reports(p_limit integer, p_offset integer); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.my_care_reports(p_limit integer, p_offset integer) FROM PUBLIC; +GRANT ALL ON FUNCTION public.my_care_reports(p_limit integer, p_offset integer) TO authenticated; +GRANT ALL ON FUNCTION public.my_care_reports(p_limit integer, p_offset integer) TO service_role; + + +-- +-- Name: FUNCTION my_received_care_reports(p_limit integer, p_offset integer); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.my_received_care_reports(p_limit integer, p_offset integer) FROM PUBLIC; +GRANT ALL ON FUNCTION public.my_received_care_reports(p_limit integer, p_offset integer) TO authenticated; +GRANT ALL ON FUNCTION public.my_received_care_reports(p_limit integer, p_offset integer) TO service_role; + + -- -- Name: FUNCTION naver_facility_id(p_name text, p_address text); Type: ACL; Schema: public; Owner: - -- diff --git a/supabase/tests/t11_care_reports_test.sql b/supabase/tests/t11_care_reports_test.sql new file mode 100644 index 0000000..8685e25 --- /dev/null +++ b/supabase/tests/t11_care_reports_test.sql @@ -0,0 +1,79 @@ +-- 케어 리포트(0028 P1) — 라이선스 게이트·발행·전화번호 HMAC 자동 연결 불변식. +begin; +set local search_path = public, app, extensions; +\ir helpers/seed.sql +select plan(9); + +-- ① 업종 라이선스(grooming) 없는 업체는 발행 불가. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='bizowner'), 'tv', 0)::text, true); +select throws_like( + $$select public.create_care_report('초코', '["u1.jpg","u2.jpg"]'::jsonb)$$, + '%license_required%', + 'grooming 라이선스 없이 발행 불가' +); + +-- 추가 시드: grooming 승인 라이선스(게이트 통과용 직접 삽입). +insert into app.business_licenses (user_id, license_type, license_no, document_path, status) +select id, 'grooming', 'T-1111', id::text || '/l.png', 'approved' + from seed where k = 'bizowner'; + +-- ② 발행 성공 — 수신자 번호는 friend(01000000002, 선택 입력). +select lives_ok( + $$select public.create_care_report('초코', '["u1.jpg","u2.jpg"]'::jsonb, + '오늘 컷 예쁘게 됐어요', '010-0000-0002')$$, + '발행 성공(전화번호 선택 입력 포함)' +); + +-- ③ 공유 링크(care_report)와 발행 계측이 생성된다. +select is( + (select count(*)::int from app.share_links l + join app.care_reports r on r.id = l.ref_id + where l.kind = 'care_report' + and r.business_id = (select id from seed where k='bizowner')), + 1, + '공유 링크 생성' +); +select is( + (select count(*)::int from app.funnel_events where event = 'report_issued'), + 1, + '발행 퍼널 이벤트 기록' +); + +-- ④ 형식이 틀린 번호는 명시 거부(자동 연결이 조용히 죽지 않게). +select throws_like( + $$select public.create_care_report('보리', '["u3.jpg"]'::jsonb, null, '1234')$$, + '%invalid_phone%', + '잘못된 번호 형식 거부' +); + +-- ⑤ friend 가 로그인해 claim → 자동 연결 1건. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='friend'), 'tv', 0)::text, true); +select is(public.claim_care_reports(), 1, '전화번호 대조 자동 연결 1건'); + +-- ⑥ 연결 결과 — claimed_by = friend, hmac 즉시 파기. +select is( + (select (r.claimed_by = (select id from seed where k='friend')) + and r.recipient_phone_hmac is null + from app.care_reports r + where r.business_id = (select id from seed where k='bizowner') + and r.pet_label = '초코'), + true, + 'claimed_by 연결 + hmac 파기' +); + +-- ⑦ 도착 알림 발송. +select is( + (select count(*)::int from public.notifications + where user_id = (select id from seed where k='friend') + and title like '초코%도착했어요'), + 1, + '도착 알림 발송' +); + +-- ⑧ 재호출은 0건(첫 claim 후 잠금). +select is(public.claim_care_reports(), 0, '재claim 0건(잠금)'); + +select * from finish(); +rollback;