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;