From 290efe4bef9fd8d8aefe67b910711f5142acb02e Mon Sep 17 00:00:00 2001 From: seizeh Date: Fri, 24 Jul 2026 13:21:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=200028=20P2=20=EC=9C=84=ED=83=81=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9E=A5=20=EC=84=9C=EB=B2=84=20=E2=80=94=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=20=EB=AA=A8=EB=8D=B8=C2=B7=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EB=B0=9C=ED=96=89=C2=B7=EC=8A=A4=EB=A0=88=EB=93=9C?= =?UTF-8?q?=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키즈노트 모델(0028 §4.4) 서버 전체: - app.care_threads(펫×업체 상시 1개, '건' 엔티티 없음) + care_reports. thread_id. 보관은 파생값(boarding_archive_days 설정, 기본 7일) — 상태 저장·크론 불필요, 새 기록 오면 자연 복귀 - create_care_thread/create_boarding_report(has_license('boarding') 게이트, 빈 발행 거부, 발행마다 단건 공유 링크 + 연결 보호자 도착 알림) - claim_care_reports 스레드 단위 확장(기존 기록 포함 연결), 받은 기록에 body·thread_id 노출, my_care_threads(archived 파생)·care_thread_reports - 뷰어 v9: 미용/돌봄 제목 분기 + 알림장 필드(식사·배변·컨디션·산책) 렌더, 전/후 배지는 미용에만. 미연결 스레드 hmac 30일 크론 파기 - pgTAP t12 14케이스 + 러너 plan 불일치 감지 보강 — 전 스위트 통과, 운영 적용·뷰어 렌더 검증 완료 Co-Authored-By: Claude Fable 5 --- docs/supabase-db.md | 21 +- scripts/run_db_tests.sh | 2 +- supabase/functions/share-view/index.ts | 28 +- .../20260724100000_boarding_journal_p2.sql | 416 ++++++++++++++++++ supabase/schema/schema.sql | 317 ++++++++++++- supabase/tests/t12_boarding_journal_test.sql | 135 ++++++ 6 files changed, 907 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/20260724100000_boarding_journal_p2.sql create mode 100644 supabase/tests/t12_boarding_journal_test.sql diff --git a/docs/supabase-db.md b/docs/supabase-db.md index fe8684a..6f5d1d2 100644 --- a/docs/supabase-db.md +++ b/docs/supabase-db.md @@ -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 스케줄 잡 @@ -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`) | --- @@ -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. 기타 diff --git a/scripts/run_db_tests.sh b/scripts/run_db_tests.sh index 9a6a3e9..04cde07 100755 --- a/scripts/run_db_tests.sh +++ b/scripts/run_db_tests.sh @@ -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 diff --git a/supabase/functions/share-view/index.ts b/supabase/functions/share-view/index.ts index 4a96009..9e578b4 100644 --- a/supabase/functions/share-view/index.ts +++ b/supabase/functions/share-view/index.ts @@ -5,7 +5,7 @@ // // 설치 전 가치 먼저(0028 원칙 2): 매장 미리보기(facility_preview)를 설치·가입 // 없이 보여주고, 설치 유도 버튼만 둔다. 후기 강요·가입 강제 없음. -// kind='care_report' 는 P1(미용 전후 사진)에서 열린다 — 지금은 안내만. +// kind='care_report'(P1 미용 전후 사진 · P2 알림장)는 사진 중심 + '내 기록 받기' CTA. // // 디자인 = 앱 업체 프로필(user_profile_screen 업체 얼굴) 미러: // 애플뮤직 스타일 헤더 카드(사진 풀블리드 + 점진 블러 + 상호·통계 2칸) → @@ -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 = { + meal: "🍚 식사", + potty: "🚽 배변", + mood: "😊 컨디션", + walk: "🐾 산책", + }; + const body = (rp.body ?? {}) as Record; + const bodyRows = Object.entries(body) + .filter(([, v]) => v != null && String(v).trim() !== "") + .map(([k, v]) => + `
${ + esc(BODY_LABELS[k] ?? k) + } · ${esc(String(v))}
` + ).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()) @@ -222,7 +239,7 @@ Deno.serve(async (req) => { }> ${ - photos.length === 2 + !isBoarding && photos.length === 2 ? `
${i === 0 ? "전" : "후"}
` : "" } @@ -230,16 +247,17 @@ Deno.serve(async (req) => { ).join("") }`; const inner = ` -
${esc(petLabel)}의 미용 기록
+
${esc(petLabel)}의 ${kindLabel}
${bizName ? `
✓ ${esc(bizName)}에서 보냈어요
` : ""} ${dateStr ? `
🗓${dateStr}
` : ""} + ${bodyRows} ${note ? `
💬${esc(note)}
` : ""}
${photoHtml} PawMate 앱에서 ${esc(petLabel)} 기록 모아보기 가입하면 다음 기록은 자동으로 도착해요`; - return html(page(`${petLabel}의 미용 기록`, `${bizName ?? "PawMate"} · 케어 리포트`, inner)); + return html(page(`${petLabel}의 ${kindLabel}`, `${bizName ?? "PawMate"} · 케어 리포트`, inner)); } if (data.kind !== "facility_preview") { diff --git a/supabase/migrations/20260724100000_boarding_journal_p2.sql b/supabase/migrations/20260724100000_boarding_journal_p2.sql new file mode 100644 index 0000000..f3a9ff8 --- /dev/null +++ b/supabase/migrations/20260724100000_boarding_journal_p2.sql @@ -0,0 +1,416 @@ +-- ============================================================================ +-- 0028 P2 — 위탁 알림장 서버: care_threads · 반복 발행 · 스레드 claim +-- +-- 키즈노트 모델(0028 §4.4): 스레드 = 반려동물 × 업체 관계 **상시 1개**, +-- '위탁 건' 엔티티는 만들지 않는다(수동 종료는 잊히고 날짜 기반은 깨진다). +-- · 기록(care_reports kind='boarding')은 스레드에 시간순으로 쌓이고 날짜 +-- 그룹핑은 앱이 담당. '건' 경계는 UI 구분선일 뿐이다. +-- · 자동 보관은 **파생값**(마지막 기록 N일 경과) — 상태 저장·크론 불필요, +-- 새 기록이 오면 자연 복귀. 임계는 care_config.boarding_archive_days +-- (기본 7, 주중 등원형 오탐 확인 시 10으로 무마이그레이션 조정, §10-1). +-- · claim 은 스레드 단위 — 보호자가 한 번 연결되면 이후 발행이 자동으로 +-- 보이고 발행 알림을 받는다. 수신자 번호는 스레드에 한 번만(선택 입력). +-- · 미가입 보호자는 매 발행마다 단건 링크(kind='care_report', 기존 뷰어 +-- 파이프라인 재사용)를 업체가 재전송한다 — 스레드 전체 웹 뷰는 만들지 +-- 않는다(개인정보 최소, 0028 원칙 6). +-- ============================================================================ + +-- (1) 보관 임계 설정(0028 §10-1 — 설정 테이블로 무마이그레이션 튜닝) +alter table app.care_config + add column if not exists boarding_archive_days smallint not null default 7; + +-- (2) 스레드 — 반려동물 × 업체 상시 관계. +-- (business_id, pet_label) 유니크는 두지 않는다 — 같은 이름의 두 아이 +-- (다른 보호자의 '초코' 둘)가 실존하므로 스레드 선택은 업체 UI 가 담당. +create table app.care_threads ( + id uuid primary key default gen_random_uuid(), + business_id uuid not null references public.users(id) on delete cascade, + pet_label varchar(50) not null, + recipient_phone_hmac bytea, -- 스레드 단위 수신자(발행마다 묻지 않게). claim 성사 시 파기 + recipient_key_version smallint not null default 1, + claimed_by uuid references public.users(id), + claimed_at timestamptz, + last_report_at timestamptz, -- 보관 파생 기준(발행 시 갱신) + created_at timestamptz not null default now() +); +create index care_threads_business_idx on app.care_threads (business_id, last_report_at desc nulls last); +create index care_threads_phone_idx on app.care_threads (recipient_phone_hmac) + where claimed_by is null and recipient_phone_hmac is not null; +create index care_threads_claimed_idx on app.care_threads (claimed_by); +comment on table app.care_threads is + '위탁 알림장 스레드(0028 §4.4) — 반려동물×업체 상시 1개, 건 엔티티 없음.'; + +-- 기록 → 스레드 연결(boarding 전용, grooming 단건은 null 유지) +alter table app.care_reports + add column if not exists thread_id uuid references app.care_threads(id) on delete cascade; +create index care_reports_thread_idx on app.care_reports (thread_id, created_at desc) + where thread_id is not null; + +-- (3) 스레드 생성 — has_license('boarding') 게이트 +create or replace function public.create_care_thread( + p_pet_label text, + p_recipient_phone text default null +) returns uuid +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_id uuid; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not app.has_license('boarding') 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 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_threads + (business_id, pet_label, recipient_phone_hmac, recipient_key_version) + values (v_uid, v_label, v_hmac, + (select key_version from app.care_config)) + returning id into v_id; + return v_id; +end; +$function$; +revoke all on function public.create_care_thread(text, text) from public; +grant execute on function public.create_care_thread(text, text) to authenticated; + +-- (4) 알림장 발행 — 스레드에 기록 추가 + 단건 공유 링크 + 연결 보호자 알림. +-- [p_body] 는 kind 별 구조 필드(식사·배변·컨디션 등) — 앱이 스키마를 정한다. +create or replace function public.create_boarding_report( + p_thread uuid, + p_photos jsonb default '[]', + p_body jsonb default '{}', + p_note text default null +) returns jsonb +language plpgsql +security definer +set search_path to '' +as $function$ +declare + v_uid uuid := app.uid(); + v_thread app.care_threads%rowtype; + 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('boarding') then + raise exception 'license_required' using errcode = 'P0001'; + end if; + select * into v_thread from app.care_threads + where id = p_thread and business_id = v_uid; + if not found then + raise exception 'thread_not_found' using errcode = 'P0001'; + end if; + if jsonb_typeof(coalesce(p_photos, '[]'::jsonb)) is distinct from 'array' + or jsonb_array_length(coalesce(p_photos, '[]'::jsonb)) > 4 then + raise exception 'invalid_photos' using errcode = 'P0001'; + end if; + if jsonb_typeof(coalesce(p_body, '{}'::jsonb)) is distinct from 'object' then + raise exception 'invalid_body' using errcode = 'P0001'; + end if; + -- 사진 없이 글만 있는 알림장도 허용하되, 둘 다 비면 거부. + if jsonb_array_length(coalesce(p_photos, '[]'::jsonb)) = 0 + and coalesce(p_body, '{}'::jsonb) = '{}'::jsonb + and nullif(btrim(coalesce(p_note, '')), '') is null then + raise exception 'empty_report' using errcode = 'P0001'; + end if; + + insert into app.care_reports + (business_id, kind, pet_label, photos, body, note, thread_id, claimed_by, claimed_at) + values + (v_uid, 'boarding', v_thread.pet_label, coalesce(p_photos, '[]'::jsonb), + coalesce(p_body, '{}'::jsonb), nullif(btrim(coalesce(p_note, '')), ''), + p_thread, + -- 스레드가 이미 연결돼 있으면 기록도 즉시 연결 상태로. + v_thread.claimed_by, case when v_thread.claimed_by is null then null else now() end) + returning id into v_report; + + update app.care_threads set last_report_at = now() where id = p_thread; + + 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); + + -- 연결 보호자에겐 발행 즉시 도착 알림(0028 §4.4 — 인앱 파이프라인). + if v_thread.claimed_by is not null then + insert into public.notifications (user_id, notification_type, is_system, title, body) + values (v_thread.claimed_by, 'system_notice', true, + v_thread.pet_label || ' 돌봄 기록이 도착했어요', + '오늘의 ' || v_thread.pet_label || ' 소식을 앱에서 확인해 보세요.'); + end if; + + return jsonb_build_object('report_id', v_report, 'token', v_token, 'expires_at', v_exp); +end; +$function$; +revoke all on function public.create_boarding_report(uuid, jsonb, jsonb, text) from public; +grant execute on function public.create_boarding_report(uuid, jsonb, jsonb, text) to authenticated; + +-- (5) 업체 스레드 목록 — 보관은 파생값(archived), 새 기록이 오면 자연 복귀. +create or replace function public.my_care_threads( + p_limit integer default 50, + p_offset integer default 0 +) returns table ( + id uuid, pet_label text, claimed_nickname text, + last_report_at timestamptz, report_count integer, + last_photo text, archived boolean +) +language sql stable security definer +set search_path to '' +as $$ + select t.id, t.pet_label::text, pr.nickname::text, + t.last_report_at, + (select count(*)::int from app.care_reports r where r.thread_id = t.id), + (select r.photos->>0 from app.care_reports r + where r.thread_id = t.id and jsonb_array_length(r.photos) > 0 + order by r.created_at desc limit 1), + coalesce( + t.last_report_at < now() - make_interval( + days => (select boarding_archive_days from app.care_config)), + false) + from app.care_threads t + left join public.public_profiles pr on pr.id = t.claimed_by + where t.business_id = app.uid() + order by t.last_report_at desc nulls last + limit least(coalesce(p_limit, 50), 200) offset coalesce(p_offset, 0); +$$; +revoke all on function public.my_care_threads(integer, integer) from public; +grant execute on function public.my_care_threads(integer, integer) to authenticated; + +-- (6) 스레드 기록 조회 — 업체(소유) 또는 연결 보호자만. 날짜 그룹핑은 앱. +create or replace function public.care_thread_reports( + p_thread uuid, + p_limit integer default 100, + p_offset integer default 0 +) returns table ( + id uuid, photos jsonb, body jsonb, note text, created_at timestamptz, token text +) +language plpgsql stable security definer +set search_path to '' +as $function$ +declare + v_uid uuid := app.uid(); +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not exists (select 1 from app.care_threads t + where t.id = p_thread + and (t.business_id = v_uid or t.claimed_by = v_uid)) then + raise exception 'thread_not_found' using errcode = 'P0001'; + end if; + return query + select r.id, r.photos, r.body, r.note, r.created_at, l.token::text + from app.care_reports r + left join app.share_links l on l.kind = 'care_report' and l.ref_id = r.id + where r.thread_id = p_thread + order by r.created_at desc + limit least(coalesce(p_limit, 100), 300) offset coalesce(p_offset, 0); +end; +$function$; +revoke all on function public.care_thread_reports(uuid, integer, integer) from public; +grant execute on function public.care_thread_reports(uuid, integer, integer) to authenticated; + +-- (7) claim 확장 — 스레드 단위 자동 연결(리포트 단건 claim 은 기존 유지). +-- 스레드 연결 시 그 스레드의 기존 기록도 함께 열린다. +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; + + -- 스레드(알림장) 자동 연결 — 기존 기록도 함께 연결 처리 + for r in + update app.care_threads t + set claimed_by = v_uid, claimed_at = now(), recipient_phone_hmac = null + where t.recipient_phone_hmac = v_hmac + and t.claimed_by is null + and t.business_id <> v_uid + returning t.id, t.pet_label + loop + v_cnt := v_cnt + 1; + update app.care_reports set claimed_by = v_uid, claimed_at = now() + where thread_id = r.id and claimed_by is null; + 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, user_id, props) + values ('claim', v_uid, jsonb_build_object('thread_id', r.id)); + end loop; + + return v_cnt; +end; +$function$; + +-- (8) 받은 기록에 스레드 기록 포함(thread_id 노출 — 앱 스레드 화면용) +drop function if exists public.my_received_care_reports(integer, integer); +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, body jsonb, note text, + created_at timestamptz, business_name text, thread_id uuid +) +language sql stable security definer +set search_path to '' +as $$ + select r.id, r.kind::text, r.pet_label::text, r.photos, r.body, r.note, + r.created_at, coalesce(b.storefront_name, b.business_name)::text, + r.thread_id + 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; + +-- (9) 뷰어 — care_report 에 kind·body 노출(알림장 렌더용) +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, 'body', r.body, '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$; + +-- (10) 미연결 스레드 hmac 파기 — 마지막 발행 30일 경과(§4.2 준용, 기존 크론 확장) +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()); + update app.care_threads t set recipient_phone_hmac = null + where t.recipient_phone_hmac is not null and t.claimed_by is null + and coalesce(t.last_report_at, t.created_at) < now() - interval '30 days'$$); diff --git a/supabase/schema/schema.sql b/supabase/schema/schema.sql index e513015..f85e0d9 100644 --- a/supabase/schema/schema.sql +++ b/supabase/schema/schema.sql @@ -3635,6 +3635,36 @@ CREATE FUNCTION public.can_manage_post_applicants(p_post uuid) RETURNS boolean $$; +-- +-- Name: care_thread_reports(uuid, integer, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.care_thread_reports(p_thread uuid, p_limit integer DEFAULT 100, p_offset integer DEFAULT 0) RETURNS TABLE(id uuid, photos jsonb, body jsonb, note text, created_at timestamp with time zone, token text) + LANGUAGE plpgsql STABLE SECURITY DEFINER + SET search_path TO '' + AS $$ +declare + v_uid uuid := app.uid(); +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not exists (select 1 from app.care_threads t + where t.id = p_thread + and (t.business_id = v_uid or t.claimed_by = v_uid)) then + raise exception 'thread_not_found' using errcode = 'P0001'; + end if; + return query + select r.id, r.photos, r.body, r.note, r.created_at, l.token::text + from app.care_reports r + left join app.share_links l on l.kind = 'care_report' and l.ref_id = r.id + where r.thread_id = p_thread + order by r.created_at desc + limit least(coalesce(p_limit, 100), 300) offset coalesce(p_offset, 0); +end; +$$; + + -- -- Name: change_password_and_rotate(uuid, text, text, integer, text, text); Type: FUNCTION; Schema: public; Owner: - -- @@ -3751,11 +3781,101 @@ begin from app.share_links l where l.kind = 'care_report' and l.ref_id = r.id; end loop; + + for r in + update app.care_threads t + set claimed_by = v_uid, claimed_at = now(), recipient_phone_hmac = null + where t.recipient_phone_hmac = v_hmac + and t.claimed_by is null + and t.business_id <> v_uid + returning t.id, t.pet_label + loop + v_cnt := v_cnt + 1; + update app.care_reports set claimed_by = v_uid, claimed_at = now() + where thread_id = r.id and claimed_by is null; + 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, user_id, props) + values ('claim', v_uid, jsonb_build_object('thread_id', r.id)); + end loop; + return v_cnt; end; $$; +-- +-- Name: create_boarding_report(uuid, jsonb, jsonb, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.create_boarding_report(p_thread uuid, p_photos jsonb DEFAULT '[]'::jsonb, p_body jsonb DEFAULT '{}'::jsonb, p_note text DEFAULT NULL::text) RETURNS jsonb + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +declare + v_uid uuid := app.uid(); + v_thread app.care_threads%rowtype; + 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('boarding') then + raise exception 'license_required' using errcode = 'P0001'; + end if; + select * into v_thread from app.care_threads + where id = p_thread and business_id = v_uid; + if not found then + raise exception 'thread_not_found' using errcode = 'P0001'; + end if; + if jsonb_typeof(coalesce(p_photos, '[]'::jsonb)) is distinct from 'array' + or jsonb_array_length(coalesce(p_photos, '[]'::jsonb)) > 4 then + raise exception 'invalid_photos' using errcode = 'P0001'; + end if; + if jsonb_typeof(coalesce(p_body, '{}'::jsonb)) is distinct from 'object' then + raise exception 'invalid_body' using errcode = 'P0001'; + end if; + if jsonb_array_length(coalesce(p_photos, '[]'::jsonb)) = 0 + and coalesce(p_body, '{}'::jsonb) = '{}'::jsonb + and nullif(btrim(coalesce(p_note, '')), '') is null then + raise exception 'empty_report' using errcode = 'P0001'; + end if; + + insert into app.care_reports + (business_id, kind, pet_label, photos, body, note, thread_id, claimed_by, claimed_at) + values + (v_uid, 'boarding', v_thread.pet_label, coalesce(p_photos, '[]'::jsonb), + coalesce(p_body, '{}'::jsonb), nullif(btrim(coalesce(p_note, '')), ''), + p_thread, + v_thread.claimed_by, case when v_thread.claimed_by is null then null else now() end) + returning id into v_report; + + update app.care_threads set last_report_at = now() where id = p_thread; + + 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); + + if v_thread.claimed_by is not null then + insert into public.notifications (user_id, notification_type, is_system, title, body) + values (v_thread.claimed_by, 'system_notice', true, + v_thread.pet_label || ' 돌봄 기록이 도착했어요', + '오늘의 ' || v_thread.pet_label || ' 소식을 앱에서 확인해 보세요.'); + end if; + + return jsonb_build_object('report_id', v_report, 'token', v_token, 'expires_at', v_exp); +end; +$$; + + -- -- Name: create_care_report(text, jsonb, text, text); Type: FUNCTION; Schema: public; Owner: - -- @@ -3813,6 +3933,46 @@ end; $$; +-- +-- Name: create_care_thread(text, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.create_care_thread(p_pet_label text, p_recipient_phone text DEFAULT NULL::text) RETURNS uuid + 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_id uuid; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not app.has_license('boarding') 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 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_threads + (business_id, pet_label, recipient_phone_hmac, recipient_key_version) + values (v_uid, v_label, v_hmac, + (select key_version from app.care_config)) + returning id into v_id; + return v_id; +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: - -- @@ -4443,16 +4603,43 @@ CREATE FUNCTION public.my_care_reports(p_limit integer DEFAULT 30, p_offset inte $$; +-- +-- Name: my_care_threads(integer, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.my_care_threads(p_limit integer DEFAULT 50, p_offset integer DEFAULT 0) RETURNS TABLE(id uuid, pet_label text, claimed_nickname text, last_report_at timestamp with time zone, report_count integer, last_photo text, archived boolean) + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path TO '' + AS $$ + select t.id, t.pet_label::text, pr.nickname::text, + t.last_report_at, + (select count(*)::int from app.care_reports r where r.thread_id = t.id), + (select r.photos->>0 from app.care_reports r + where r.thread_id = t.id and jsonb_array_length(r.photos) > 0 + order by r.created_at desc limit 1), + coalesce( + t.last_report_at < now() - make_interval( + days => (select boarding_archive_days from app.care_config)), + false) + from app.care_threads t + left join public.public_profiles pr on pr.id = t.claimed_by + where t.business_id = app.uid() + order by t.last_report_at desc nulls last + limit least(coalesce(p_limit, 50), 200) 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) +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, body jsonb, note text, created_at timestamp with time zone, business_name text, thread_id uuid) 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 + select r.id, r.kind::text, r.pet_label::text, r.photos, r.body, r.note, + r.created_at, coalesce(b.storefront_name, b.business_name)::text, + r.thread_id from app.care_reports r left join public.business_profiles b on b.user_id = r.business_id where r.claimed_by = app.uid() @@ -5139,7 +5326,7 @@ begin '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, + 'kind', r.kind, 'body', r.body, 'created_at', r.created_at, 'business_name', coalesce(b.storefront_name, b.business_name))) into v_out from app.care_reports r @@ -5609,6 +5796,7 @@ 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, + boarding_archive_days smallint DEFAULT 7 NOT NULL, CONSTRAINT care_config_singleton CHECK (id) ); @@ -5630,6 +5818,7 @@ CREATE TABLE app.care_reports ( claimed_by uuid, claimed_at timestamp with time zone, created_at timestamp with time zone DEFAULT now() NOT NULL, + thread_id uuid, CONSTRAINT care_reports_kind_check CHECK (((kind)::text = ANY ((ARRAY['grooming'::character varying, 'boarding'::character varying])::text[]))) ); @@ -5641,6 +5830,30 @@ CREATE TABLE app.care_reports ( COMMENT ON TABLE app.care_reports IS '업체→보호자 케어 리포트(0028 §4 — 미용 전후 사진·P2 알림장 공용 원형).'; +-- +-- Name: care_threads; Type: TABLE; Schema: app; Owner: - +-- + +CREATE TABLE app.care_threads ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + business_id uuid NOT NULL, + pet_label character varying(50) NOT NULL, + recipient_phone_hmac bytea, + recipient_key_version smallint DEFAULT 1 NOT NULL, + claimed_by uuid, + claimed_at timestamp with time zone, + last_report_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: TABLE care_threads; Type: COMMENT; Schema: app; Owner: - +-- + +COMMENT ON TABLE app.care_threads IS '위탁 알림장 스레드(0028 §4.4) — 반려동물×업체 상시 1개, 건 엔티티 없음.'; + + -- -- Name: funnel_events; Type: TABLE; Schema: app; Owner: - -- @@ -7061,6 +7274,14 @@ ALTER TABLE ONLY app.care_reports ADD CONSTRAINT care_reports_pkey PRIMARY KEY (id); +-- +-- Name: care_threads care_threads_pkey; Type: CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_threads + ADD CONSTRAINT care_threads_pkey PRIMARY KEY (id); + + -- -- Name: funnel_events funnel_events_pkey; Type: CONSTRAINT; Schema: app; Owner: - -- @@ -7562,6 +7783,34 @@ CREATE INDEX care_reports_claimed_idx ON app.care_reports USING btree (claimed_b 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: care_reports_thread_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_reports_thread_idx ON app.care_reports USING btree (thread_id, created_at DESC) WHERE (thread_id IS NOT NULL); + + +-- +-- Name: care_threads_business_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_threads_business_idx ON app.care_threads USING btree (business_id, last_report_at DESC NULLS LAST); + + +-- +-- Name: care_threads_claimed_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_threads_claimed_idx ON app.care_threads USING btree (claimed_by); + + +-- +-- Name: care_threads_phone_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX care_threads_phone_idx ON app.care_threads 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: - -- @@ -8673,6 +8922,30 @@ ALTER TABLE ONLY app.care_reports ADD CONSTRAINT care_reports_claimed_by_fkey FOREIGN KEY (claimed_by) REFERENCES public.users(id); +-- +-- Name: care_reports care_reports_thread_id_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_reports + ADD CONSTRAINT care_reports_thread_id_fkey FOREIGN KEY (thread_id) REFERENCES app.care_threads(id) ON DELETE CASCADE; + + +-- +-- Name: care_threads care_threads_business_id_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_threads + ADD CONSTRAINT care_threads_business_id_fkey FOREIGN KEY (business_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: care_threads care_threads_claimed_by_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.care_threads + ADD CONSTRAINT care_threads_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: - -- @@ -10388,6 +10661,15 @@ GRANT ALL ON FUNCTION public.can_manage_post_applicants(p_post uuid) TO authenti GRANT ALL ON FUNCTION public.can_manage_post_applicants(p_post uuid) TO service_role; +-- +-- Name: FUNCTION care_thread_reports(p_thread uuid, p_limit integer, p_offset integer); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.care_thread_reports(p_thread uuid, p_limit integer, p_offset integer) FROM PUBLIC; +GRANT ALL ON FUNCTION public.care_thread_reports(p_thread uuid, p_limit integer, p_offset integer) TO authenticated; +GRANT ALL ON FUNCTION public.care_thread_reports(p_thread uuid, p_limit integer, p_offset integer) TO service_role; + + -- -- Name: FUNCTION change_password_and_rotate(p_user uuid, p_current_hash text, p_new_hash text, p_tv integer, p_new_token_hash text, p_user_agent text); Type: ACL; Schema: public; Owner: - -- @@ -10424,6 +10706,15 @@ GRANT ALL ON FUNCTION public.claim_care_reports() TO authenticated; GRANT ALL ON FUNCTION public.claim_care_reports() TO service_role; +-- +-- Name: FUNCTION create_boarding_report(p_thread uuid, p_photos jsonb, p_body jsonb, p_note text); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.create_boarding_report(p_thread uuid, p_photos jsonb, p_body jsonb, p_note text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.create_boarding_report(p_thread uuid, p_photos jsonb, p_body jsonb, p_note text) TO authenticated; +GRANT ALL ON FUNCTION public.create_boarding_report(p_thread uuid, p_photos jsonb, p_body jsonb, p_note text) 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: - -- @@ -10433,6 +10724,15 @@ GRANT ALL ON FUNCTION public.create_care_report(p_pet_label text, p_photos jsonb 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_care_thread(p_pet_label text, p_recipient_phone text); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.create_care_thread(p_pet_label text, p_recipient_phone text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.create_care_thread(p_pet_label text, p_recipient_phone text) TO authenticated; +GRANT ALL ON FUNCTION public.create_care_thread(p_pet_label 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: - -- @@ -10608,6 +10908,15 @@ GRANT ALL ON FUNCTION public.my_care_reports(p_limit integer, p_offset integer) GRANT ALL ON FUNCTION public.my_care_reports(p_limit integer, p_offset integer) TO service_role; +-- +-- Name: FUNCTION my_care_threads(p_limit integer, p_offset integer); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.my_care_threads(p_limit integer, p_offset integer) FROM PUBLIC; +GRANT ALL ON FUNCTION public.my_care_threads(p_limit integer, p_offset integer) TO authenticated; +GRANT ALL ON FUNCTION public.my_care_threads(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: - -- diff --git a/supabase/tests/t12_boarding_journal_test.sql b/supabase/tests/t12_boarding_journal_test.sql new file mode 100644 index 0000000..d0e4a1d --- /dev/null +++ b/supabase/tests/t12_boarding_journal_test.sql @@ -0,0 +1,135 @@ +-- 위탁 알림장(0028 P2) — 스레드 모델·반복 발행·스레드 claim·보관 파생 불변식. +begin; +set local search_path = public, app, extensions; +\ir helpers/seed.sql +select plan(14); + +create temp table t12 (k text primary key, id uuid not null); + +-- ① boarding 라이선스 없는 업체는 스레드 생성 불가. +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_thread('구름')$$, + '%license_required%', + 'boarding 라이선스 없이 스레드 생성 불가' +); + +-- 추가 시드: boarding 승인 라이선스. +insert into app.business_licenses (user_id, license_type, license_no, document_path, status) +select id, 'boarding', 'T-2222', id::text || '/l.png', 'approved' + from seed where k = 'bizowner'; + +-- ② 스레드 생성(수신자 = friend 번호, 선택 입력). +with t as ( + select public.create_care_thread('구름', '010-0000-0002') as id +) +insert into t12 select 'th', id from t; +select is( + (select count(*)::int from app.care_threads + where id = (select id from t12 where k='th')), + 1, + '스레드 생성 성공' +); + +-- ③ 알림장 발행 — 사진+식사·배변 body. +select lives_ok( + $$select public.create_boarding_report( + (select id from t12 where k='th'), + '["b1.jpg"]'::jsonb, + '{"meal":"사료 잘 먹음","potty":"정상"}'::jsonb, + '오늘 산책도 신나게 했어요')$$, + '알림장 발행 성공' +); +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.thread_id = (select id from t12 where k='th')), + 1, + '발행마다 단건 공유 링크 생성' +); + +-- ④ 전부 빈 발행은 거부. +select throws_like( + $$select public.create_boarding_report((select id from t12 where k='th'))$$, + '%empty_report%', + '빈 알림장 거부' +); + +-- ⑤ friend claim → 스레드 연결 + 기존 기록도 함께 연결. +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건'); +select is( + (select bool_and(r.claimed_by = (select id from seed where k='friend')) + from app.care_reports r + where r.thread_id = (select id from t12 where k='th')), + true, + '기존 기록도 연결됨' +); + +-- ⑥ 연결 후 발행 — 기록 즉시 연결 + 도착 알림. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='bizowner'), 'tv', 0)::text, true); +select lives_ok( + $$select public.create_boarding_report( + (select id from t12 where k='th'), '["b2.jpg"]'::jsonb)$$, + '연결 후 발행 성공' +); +select is( + (select count(*)::int from public.notifications + where user_id = (select id from seed where k='friend') + and title = '구름 돌봄 기록이 도착했어요'), + 1, + '연결 보호자에게 도착 알림' +); + +-- ⑦ 보호자는 스레드 기록 조회 가능. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='friend'), 'tv', 0)::text, true); +select is( + (select count(*)::int from public.care_thread_reports((select id from t12 where k='th'))), + 2, + '연결 보호자 스레드 기록 조회' +); + +-- ⑧ 제3자는 접근 불가. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='owner'), 'tv', 0)::text, true); +select throws_like( + $$select * from public.care_thread_reports((select id from t12 where k='th'))$$, + '%thread_not_found%', + '제3자 스레드 접근 거부' +); + +-- ⑨ 받은 기록에 알림장 포함(thread_id 노출). +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='friend'), 'tv', 0)::text, true); +select is( + (select count(*)::int from public.my_received_care_reports() + where kind = 'boarding' and thread_id = (select id from t12 where k='th')), + 2, + '받은 기록에 알림장 포함' +); + +-- ⑩ 보관은 파생값 — 마지막 발행을 임계(7일) 밖으로 밀면 archived=true, +-- 새 기록이 오면(위 ⑥ 이후 상태) false 였음을 함께 확인. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='bizowner'), 'tv', 0)::text, true); +select is( + (select archived from public.my_care_threads() + where id = (select id from t12 where k='th')), + false, + '최근 발행 스레드는 활성' +); +update app.care_threads set last_report_at = now() - interval '8 days' + where id = (select id from t12 where k='th'); +select is( + (select archived from public.my_care_threads() + where id = (select id from t12 where k='th')), + true, + '무입력 7일 경과 → 보관(파생)' +); + +select * from finish(); +rollback;