From d7d3f5afe86c0b00ad6a6f3e4e7470895b18c43d Mon Sep 17 00:00:00 2001 From: seizeh Date: Fri, 24 Jul 2026 14:06:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=200028=20P3=20=EB=B6=84=EC=96=91=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=ED=84=B0=20+=20=ED=8C=8C=EC=9D=BC=EB=9F=BF?= =?UTF-8?q?=C2=B7=EC=B6=9C=EC=8B=9C=20=EC=9A=B4=EC=98=81=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스타터 QR 랜딩: share_links kind 'starter' + admin_create_starter_share_link (승인 업체+sales/production 허가 게이트, 토큰 재사용) + share_view_load starter 분기 + 뷰어 랜딩(스타터 소개·설치 CTA, v10 배포) - 접종 알림: app.vaccination_events + set/my/done RPC(보호자 게이트, 일정 콘텐츠는 앱) + vaccine-reminder-sweep 크론(매일 09:00 KST, D-1·당일 1회, 지난 일정 무음 소화) + notification_type 'vaccine_reminder'·resource 'pet' - 워커: /.well-known AASA 서빙(유니버설 링크 서버측 완료), assetlinks 는 릴리스 지문 확보 전 404 (배포됨) - pgTAP t13(17단언) — 전 스위트 운영 DB 통과, 스키마 스냅샷 갱신 - docs: 미용 파일럿 온보딩 체크리스트, 출시 체크리스트 마이그레이션 운영 적용 완료(starter_p3). Co-Authored-By: Claude Fable 5 --- docs/launch-checklist.md | 59 +++ docs/pilot-onboarding-checklist.md | 67 ++++ supabase/functions/share-view/index.ts | 26 ++ .../migrations/20260724150000_starter_p3.sql | 346 ++++++++++++++++++ supabase/schema/schema.sql | 276 +++++++++++++- supabase/tests/t13_starter_p3_test.sql | 215 +++++++++++ workers/share-proxy/src/index.js | 44 +++ 7 files changed, 1030 insertions(+), 3 deletions(-) create mode 100644 docs/launch-checklist.md create mode 100644 docs/pilot-onboarding-checklist.md create mode 100644 supabase/migrations/20260724150000_starter_p3.sql create mode 100644 supabase/tests/t13_starter_p3_test.sql diff --git a/docs/launch-checklist.md b/docs/launch-checklist.md new file mode 100644 index 0000000..ef18ba9 --- /dev/null +++ b/docs/launch-checklist.md @@ -0,0 +1,59 @@ +# 출시 준비 체크리스트 (0028 — 스토어 URL · 유니버설 링크 · 루트 리다이렉트) + +> 스토어 심사 통과로 실제 스토어 URL 이 나오는 시점에 실행하는 것들. +> 사전 준비(코드·인프라)는 전부 완료 상태이며, 각 항목은 값 채우기 수준이다. + +## 1. 스토어 URL 시크릿 (share-view CTA 실동작 전환) + +`share-view` 는 `?go=store` 에서 `STORE_URL_IOS`/`STORE_URL_ANDROID` 시크릿이 +비어 있으면 "앱 출시 준비 중" 안내를 보여준다(index.ts). 시크릿을 채우는 즉시 +모든 공유 링크·QR 의 설치 버튼이 스토어 302 로 전환된다 — 재배포 불필요. + +- [ ] App Store 등록 완료 → URL 확보 (`https://apps.apple.com/kr/app/…`) +- [ ] Play 스토어 등록 완료 → URL 확보 (`https://play.google.com/store/apps/details?id=com.seizeh.pawmate`) +- [ ] Supabase 시크릿 설정: + ``` + supabase secrets set STORE_URL_IOS=… STORE_URL_ANDROID=… --project-ref vyatppuxmpulqtxevfpk + ``` + (대시보드 Edge Functions → Secrets 로도 가능. 설정 후 `?go=store` 스모크 테스트.) + +## 2. pawmate.kr 루트 — 임시 랜딩 → 스토어 리다이렉트 + +`workers/share-proxy/src/index.js` 의 `LANDING` 이 임시 안내를 서빙 중. + +- [ ] 루트 처리 교체: UA 분기로 iOS→App Store / Android→Play / 데스크톱→간단 소개 + (share-view 의 UA 정규식 `iphone|ipad|ipod|macintosh` 재사용). +- [ ] `workers/share-proxy` 에서 `npx wrangler deploy`. + +## 3. 유니버설 링크 (iOS) — 설치자가 공유 링크를 앱으로 열기 + +서버 쪽은 **완료**: Worker 가 `go.pawmate.kr/.well-known/apple-app-site-association` +을 서빙 중(appID `5GVP46ZJ2H.com.seizeh.pawmate`, path `/s`). 남은 것은 앱 쪽: + +- [ ] Xcode → Runner 타깃 → Signing & Capabilities → **Associated Domains** 추가: + `applinks:go.pawmate.kr` (Runner.entitlements 에 + `com.apple.developer.associated-domains` 로 기록됨. 유료 팀 5GVP46ZJ2H 이므로 + capability 활성화 가능) +- [ ] 앱 링크 수신 배선: `app_links` 패키지를 직접 의존성으로 승격 → + 수신한 `/s?t=…` 를 라우팅. v1 라우팅 제안: 로그인 상태면 + `claim_care_reports()` 호출 후 받은 케어 기록 화면(연결된 기록이 도착해 있는 + 흐름), 비로그인이면 웰컴 화면. 토큰별 정밀 라우팅(kind 해석 RPC)은 후속. +- [ ] 실기기 검증: 링크를 메모장에 붙여 길게 눌러 "PawMate 로 열기" 확인. + +## 4. 앱 링크 (Android) + +- [ ] 릴리스 서명키 확정 후 SHA-256 지문 추출: + `keytool -list -printcert -keystore ` (Play App Signing 사용 시 + Play Console → 앱 무결성에서 지문 확인) +- [ ] `workers/share-proxy/src/index.js` 의 `ANDROID_CERT_SHA256` 에 지문 추가 → + deploy (빈 배열이면 `/.well-known/assetlinks.json` 은 404 로 안전하게 꺼져 있음) +- [ ] `AndroidManifest.xml` MainActivity 에 `autoVerify` intent-filter 추가: + `https` + `go.pawmate.kr` + pathPrefix `/s` +- [ ] `adb shell pm get-app-links com.seizeh.pawmate` 로 verified 확인. + +## 5. 출시 시점 운영 + +- [ ] 위치기반서비스사업 신고 수리 확인(캠페인 전제 — 0028 원칙 7) +- [ ] pmlegal 문서의 앱 내 링크·시행일 최종 확인 +- [ ] 파일럿 QR 인쇄물은 그대로 유효(주소가 `go.pawmate.kr` 라 백엔드 교체 무관) +- [ ] iOS `aps-environment` 가 릴리스 빌드에서 production 인지 확인(푸시) diff --git a/docs/pilot-onboarding-checklist.md b/docs/pilot-onboarding-checklist.md new file mode 100644 index 0000000..2fbb36a --- /dev/null +++ b/docs/pilot-onboarding-checklist.md @@ -0,0 +1,67 @@ +# 미용 파일럿 — 업체 온보딩 체크리스트 (0028 P1 실행) + +> 대상: 1개 동네 미용업체 5~10곳(밀도 우선, 0028 원칙 5). 이 문서는 업체 1곳을 +> 섭외부터 도구 사용까지 올리는 운영 절차다. 서버·앱은 전부 구현 완료 상태 +> (P0~P3)이며, 남은 것은 사람이 하는 일이다. + +## 시작 전 전제 (전체 파일럿 공통, 1회) + +- [ ] **위치기반서비스사업 신고(방통위) 수리** — 수리 전 오프라인 캠페인 금지 + (0028 원칙 7). 준비 상태는 pmlegal `05신고준비체크리스트.md`. +- [ ] pmlegal 개정 시행일 경과 확인 — 대가성 후기·업종 인증·케어 리포트 조항 + (공고 2026-07-24 / **시행 2026-07-31**). 시행 전 도구 실사용 금지. +- [ ] `go.pawmate.kr` 뷰어 정상 동작 스모크 테스트(QR 스캔 → 열람 → 스토어 버튼). +- [ ] 관리자 계정으로 QR 화면 접근 가능 확인. + +## 업체별 온보딩 (업체마다 반복) + +### 1. 섭외·약정 +- [ ] 도구 데모 보여주기 — 핵심 화법: "미용 끝나고 전후 사진, 이 앱으로 보내면 + 손님 카톡에 예쁘게 도착해요. 무료예요." +- [ ] 제휴 약정 체결. 필수 조항: + - 분양 관련 홍보 문구 게재 금지(0028 §0 — 위반 시 노출 중단) + - 대가성 후기 표시를 숨기도록 유도 금지(약관 제9조의3 ④) + - 고객 전화번호 입력 시 고객 고지·동의는 업체 책임(약관 제10조의3 ④) + +### 2. 계정·인증 +- [ ] 사장님 폰에 앱 설치 → 가입(업체 유형) → 업체 등록 폼 제출 + (사업자등록증 + **미용업 등록증 동시 제출** — 신청 두 번 안 하게). +- [ ] 관리자: 업체 인증 승인 → 업종(grooming) 라이선스 승인. +- [ ] 사장님 폰에서 케어 리포트 발행 진입점 보이는지 확인. + +### 3. QR 발급·인쇄 +- [ ] 관리자 QR 화면에서 매장 미리보기 링크 발급(`admin_create_facility_share_link` + — 재호출해도 같은 토큰 재사용이라 재인쇄 안전). +- [ ] 인쇄물 제작 — 주소는 반드시 `go.pawmate.kr/s?t=…`. **supabase.co 주소 + 절대 금지**(게이트웨이 HTML 차단 + 도메인 영속성). +- [ ] 매장 비치(카운터·대기석) + 스캔 테스트. + +### 4. 도구 교육 (5분) +- [ ] 발행 흐름: 업체 모드 → 케어 리포트 → 사진(전/후) → 아이 이름 → + 전화번호(**선택** — "번호를 넣으면 손님 가입 시 자동 연결") → 발행 → + 공유 시트로 손님 카톡·문자 전송(업체 폰에서). +- [ ] 전화번호 입력 전 고객에게 한마디 안내 습관화: "사진 보내드릴게요, + 번호 입력할게요~" (약관상 고지 의무). +- [ ] 대가성 후기 정책: 할인·사은품 주고 후기 부탁할 땐 손님이 체크박스 + 선택하도록 안내(배지가 붙을 뿐 후기 노출엔 불이익 없음). +- [ ] 습관 목표: **미용 완료마다 1회 발행**. 첫 주에 발행 0이면 재방문 코칭. + +## 주간 모니터링 (파일럿 기간) + +`app.funnel_events` 집계 — 매장(token)별: + +| 지표 | 이벤트 | 비고 | +| --- | --- | --- | +| 발행 수 | `report_issued` | **1순위 지표 = 업체 재사용률**(주당 발행 유지 여부) | +| 열람율 | `share_view` / 발행 | 링크 전송이 실제로 되는지 | +| 스토어 클릭율 | `store_click` / `share_view` | 출시 전엔 "준비 중" 페이지 노출 | +| claim 율 | `claim` / `report_issued` | 자동 연결(전화번호 대조) 성사 | +| 후기·D7 잔존 | 앱 지표 | 가입 후 정착 | + +- [ ] 발행→claim 이탈이 크면 그때 딥링크 SDK 검토를 연다(0028 §4.3 — 그 전엔 금지). +- [ ] 업체별 폴백 승인 비율 모니터링(비정상 업체 = 오·남용 조사 대상). + +## 파일럿 종료 판단 (0028 §7) + +- 업체 재사용률이 유지되는가(도구가 사장님에게 실제로 쓸 이유가 됐는가) — 이 전략의 + 가설 그 자체. 유지되면 위탁(P2 알림장) 업체로 확장, 아니면 도구 개선 후 재시도. diff --git a/supabase/functions/share-view/index.ts b/supabase/functions/share-view/index.ts index 9e578b4..4fae969 100644 --- a/supabase/functions/share-view/index.ts +++ b/supabase/functions/share-view/index.ts @@ -260,6 +260,32 @@ Deno.serve(async (req) => { return html(page(`${petLabel}의 ${kindLabel}`, `${bizName ?? "PawMate"} · 케어 리포트`, inner)); } + // ── 분양 스타터 랜딩(P3) — 본문은 정적 콘텐츠, 업체명만 데이터(출처 표기·계측) ── + if (data.kind === "starter") { + const st = data.starter ?? {}; + const bizName = st.business_name ? String(st.business_name) : null; + const inner = ` +
새 가족의 시작을 함께해요
+
+ ${bizName ? `
✓ ${esc(bizName)}에서 준비했어요
` : ""} +
🎁새 보호자를 위한 스타터 패키지예요. + 앱을 설치하고 "최근에 아이를 데려왔어요"를 선택하면 바로 시작돼요.
+
+
패키지에 담긴 것
+
+
💉접종 일정 알림 — 종합백신부터 광견병까지, 시기마다 잊지 않게 알려드려요
+
초보 보호자 체크리스트 — 처음 한 달, 챙길 것들을 순서대로
+
🗺동네 병원·미용 지도 — 우리 동네 반려 시설을 후기와 함께
+
+ PawMate 앱에서 스타터 패키지 받기 + 설치와 가입은 무료예요`; + return html(page( + "새 보호자 스타터 패키지", + `${bizName ?? "PawMate"} · 새 가족 맞이 선물`, + inner, + )); + } + if (data.kind !== "facility_preview") { return noticePage("준비 중인 콘텐츠예요", "앱에서 곧 만나볼 수 있어요.", 200); } diff --git a/supabase/migrations/20260724150000_starter_p3.sql b/supabase/migrations/20260724150000_starter_p3.sql new file mode 100644 index 0000000..4c82fa0 --- /dev/null +++ b/supabase/migrations/20260724150000_starter_p3.sql @@ -0,0 +1,346 @@ +-- ============================================================================ +-- 0028 P3 — 분양 스타터: 스타터 QR 랜딩 · 접종 일정 알림 +-- +-- 분양업체는 입점 파트너가 아니라 **가입 유통 채널**(0028 원칙 4) — 앱 안에 +-- 분양업체용 기능·노출은 만들지 않고, 새 보호자에게 스타터 패키지(접종 알림· +-- 체크리스트·동네 지도)를 QR 랜딩으로 전달한다(§5). +-- · 스타터 QR: kind='starter' 공유 링크(ref = 업체 계정) — 업체별 고유 토큰으로 +-- 스캔→열람→스토어 클릭이 매장별로 계측된다(§7). 발급은 관리자 운영 도구 +-- (sales/production 허가 승인 업체 명단 대상, §1.3 — 인앱 모듈은 열지 않음). +-- · 접종 알림: 표준 접종 일정(항목·날짜 계산은 앱 콘텐츠)을 이벤트로 저장하고, +-- 매일 아침 크론이 D-1·당일분을 펫 보호자 전원에게 알림(0027 파이프라인). +-- 리마인더일 뿐 수의학적 판단은 담지 않는다. 이벤트당 알림은 1회 +-- (notified_at 잠금), 등록 시점에 이미 지난 일정은 조용히 소화한다. +-- ============================================================================ + +-- (1) share_links kind 확장 — 'starter' +alter table app.share_links drop constraint share_links_kind_check; +alter table app.share_links add constraint share_links_kind_check + check (kind in ('facility_preview', 'care_report', 'starter')); + +-- (2) 스타터 QR 발급 — 관리자 전용. facility 링크와 같은 재사용 원칙 +-- (업체당 QR 1장, 재호출로 기존 인쇄물이 무효화되지 않게). +create or replace function public.admin_create_starter_share_link( + p_business uuid, + p_days integer default 365 +) returns table (token varchar, expires_at timestamptz) +language plpgsql +security definer +set search_path to 'public' +as $function$ +declare + v_token varchar(32); + v_exp timestamptz; +begin + if not app.is_admin() then + raise exception 'forbidden' using errcode = '42501'; + end if; + if p_days < 1 or p_days > 3650 then + raise exception 'days 1..3650'; + end if; + -- 발급 명단 = 승인 업체 + 판매/생산 허가 승인(0028 §1.3) + if not exists (select 1 from public.business_profiles b + where b.user_id = p_business and b.status = 'approved') then + raise exception 'business_not_approved'; + end if; + if not exists (select 1 from app.business_licenses l + where l.user_id = p_business + and l.license_type in ('sales', 'production') + and l.status = 'approved') then + raise exception 'starter_license_required'; + end if; + + -- 유효(미회수·미만료) 링크 재사용 + select l.token, l.expires_at into v_token, v_exp + from app.share_links l + where l.kind = 'starter' and l.ref_id = p_business + and l.revoked_at is null and l.expires_at > now() + order by l.created_at desc limit 1; + if v_token is not null then + return query select v_token, v_exp; + return; + end if; + + v_token := encode(extensions.gen_random_bytes(16), 'hex'); + v_exp := now() + make_interval(days => p_days); + insert into app.share_links (token, kind, ref_id, created_by, expires_at) + values (v_token, 'starter', p_business, app.uid(), v_exp); + return query select v_token, v_exp; +end; +$function$; +revoke all on function public.admin_create_starter_share_link(uuid, integer) from public; +grant execute on function public.admin_create_starter_share_link(uuid, integer) to authenticated; + +-- (3) 뷰어 — starter 분기 추가(업체명만 — 랜딩 본문은 정적 콘텐츠, Edge Function) +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; + + if v_link.kind = 'starter' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'starter', jsonb_build_object( + 'business_name', coalesce(b.storefront_name, b.business_name))) + into v_out + from public.business_profiles b + where b.user_id = v_link.ref_id and b.status = 'approved'; + -- 업체가 인증 취소돼도 랜딩 자체는 살아있게(출처 표기만 생략) + return coalesce(v_out, jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'starter', jsonb_build_object('business_name', null))); + end if; + + return jsonb_build_object('status', 'ok', 'kind', v_link.kind); +end; +$function$; + +-- (4) 접종 일정 이벤트 — 앱이 계산한 표준 일정(라벨+날짜)을 저장만 한다. +create table app.vaccination_events ( + id uuid primary key default gen_random_uuid(), + pet_id uuid not null references public.pets(id) on delete cascade, + label varchar(60) not null, -- '종합백신 2차' 등(앱 콘텐츠) + due_date date not null, + done_at timestamptz, -- 보호자 완료 체크 + notified_at timestamptz, -- 발송 1회 잠금 + created_by uuid not null references public.users(id) on delete cascade, + created_at timestamptz not null default now() +); +create index vaccination_events_pet_idx on app.vaccination_events (pet_id, due_date); +create index vaccination_events_due_idx on app.vaccination_events (due_date) + where done_at is null and notified_at is null; +comment on table app.vaccination_events is + '분양 스타터 접종 리마인더(0028 §5) — 일정 콘텐츠는 앱, 서버는 저장·알림만.'; + +-- (5) 일정 저장 — 보호자(pet_guardians) 전용. 미완료분을 통째로 교체한다 +-- (완료 체크된 이벤트는 기록으로 보존). p_source 는 퍼널 계측용 +-- ('onboarding' = 가입 분기, 'manage' = 펫 화면). +create or replace function public.set_vaccination_schedule( + p_pet uuid, + p_events jsonb, + p_source text default null +) returns integer +language plpgsql +security definer +set search_path to '' +as $function$ +declare + v_uid uuid := app.uid(); + v_cnt integer := 0; + e jsonb; + v_label text; + v_due date; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not exists (select 1 from public.pet_guardians g + where g.pet_id = p_pet and g.user_id = v_uid) then + raise exception 'not_guardian' using errcode = 'P0001'; + end if; + if jsonb_typeof(coalesce(p_events, '[]'::jsonb)) is distinct from 'array' + or jsonb_array_length(coalesce(p_events, '[]'::jsonb)) > 40 then + raise exception 'invalid_events' using errcode = 'P0001'; + end if; + if p_source is not null and p_source not in ('onboarding', 'manage') then + raise exception 'invalid_source' using errcode = 'P0001'; + end if; + + delete from app.vaccination_events + where pet_id = p_pet and done_at is null; + + for e in select value from jsonb_array_elements(coalesce(p_events, '[]'::jsonb)) + loop + v_label := nullif(btrim(coalesce(e->>'label', '')), ''); + if v_label is null or length(v_label) > 60 + or coalesce(e->>'due_date', '') !~ '^\d{4}-\d{2}-\d{2}$' then + raise exception 'invalid_events' using errcode = 'P0001'; + end if; + v_due := (e->>'due_date')::date; + if v_due not between current_date - 730 and current_date + 1095 then + raise exception 'invalid_events' using errcode = 'P0001'; + end if; + insert into app.vaccination_events (pet_id, label, due_date, created_by) + values (p_pet, v_label, v_due, v_uid); + v_cnt := v_cnt + 1; + end loop; + + insert into app.funnel_events (event, user_id, props) + values ('vaccine_schedule', v_uid, + jsonb_build_object('pet_id', p_pet, 'count', v_cnt, + 'source', p_source)); + return v_cnt; +end; +$function$; +revoke all on function public.set_vaccination_schedule(uuid, jsonb, text) from public; +grant execute on function public.set_vaccination_schedule(uuid, jsonb, text) to authenticated; + +-- (6) 일정 조회 — 보호자 전용, 날짜순. +create or replace function public.my_vaccination_events(p_pet uuid) +returns table (id uuid, label text, due_date date, done_at timestamptz) +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 public.pet_guardians g + where g.pet_id = p_pet and g.user_id = v_uid) then + raise exception 'not_guardian' using errcode = 'P0001'; + end if; + return query + select e.id, e.label::text, e.due_date, e.done_at + from app.vaccination_events e + where e.pet_id = p_pet + order by e.due_date, e.created_at; +end; +$function$; +revoke all on function public.my_vaccination_events(uuid) from public; +grant execute on function public.my_vaccination_events(uuid) to authenticated; + +-- (7) 완료 체크 토글 — 보호자 전용. +create or replace function public.set_vaccination_done( + p_event uuid, + p_done boolean default true +) returns boolean +language plpgsql +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; + update app.vaccination_events e + set done_at = case when p_done then now() else null end + where e.id = p_event + and exists (select 1 from public.pet_guardians g + where g.pet_id = e.pet_id and g.user_id = v_uid); + return found; +end; +$function$; +revoke all on function public.set_vaccination_done(uuid, boolean) from public; +grant execute on function public.set_vaccination_done(uuid, boolean) to authenticated; + +-- (8) 알림 타입·리소스 확장 — vaccine_reminder / resource 'pet' +alter table public.notifications drop constraint notifications_notification_type_check; +alter table public.notifications add constraint notifications_notification_type_check + check (((notification_type)::text = any (array[ + 'chat_message'::text, 'post_application'::text, 'post_comment'::text, + 'pawing_new_post'::text, 'application_accepted'::text, + 'application_accepted_by_co'::text, 'review_received'::text, + 'guardian_invite'::text, 'system_notice'::text, 'location_expired'::text, + 'chat_read_receipt'::text, 'unread_sync'::text, 'security_login'::text, + 'schedule_changed'::text, 'business_approved'::text, 'business_rejected'::text, + 'review_comment'::text, 'post_heart'::text, 'pawing_follow'::text, + 'facility_review_received'::text, 'pet_in_post'::text, 'vaccine_reminder'::text]))); + +alter table public.notifications drop constraint notifications_resource_type_check; +alter table public.notifications add constraint notifications_resource_type_check + check (((resource_type is null) or ((resource_type)::text = any (array[ + 'post'::text, 'comment'::text, 'chat_room'::text, 'appointment'::text, + 'facility_review'::text, 'user'::text, 'pet'::text])))); + +-- (9) 접종 리마인더 크론 — 매일 09:00 KST(00:00 UTC). D-1·당일분을 보호자 +-- 전원에게 1회 알림. 등록 시점에 이미 지난 일정은 notified_at 만 찍고 +-- 알림 없이 소화한다(뒤늦은 등록이 알림 폭탄이 되지 않게). +do $$ begin + if exists (select 1 from cron.job where jobname = 'vaccine-reminder-sweep') then + perform cron.unschedule('vaccine-reminder-sweep'); + end if; +end $$; +select cron.schedule('vaccine-reminder-sweep', '0 0 * * *', + $$with due as ( + update app.vaccination_events e + set notified_at = now() + where e.done_at is null and e.notified_at is null + and e.due_date <= (now() at time zone 'Asia/Seoul')::date + 1 + returning e.pet_id, e.label, e.due_date + ) + insert into public.notifications + (user_id, notification_type, is_system, title, body, resource_type, resource_id) + select g.user_id, 'vaccine_reminder', true, + p.name || ' 접종일이 다가와요', + d.label || ' — ' || + case when d.due_date <= (now() at time zone 'Asia/Seoul')::date + then '오늘' else '내일' end || ' 예정이에요. 병원 일정을 확인해 주세요.', + 'pet', d.pet_id + from due d + join public.pets p on p.id = d.pet_id and p.pet_status = 'active' + join public.pet_guardians g on g.pet_id = d.pet_id + where d.due_date >= (now() at time zone 'Asia/Seoul')::date$$); diff --git a/supabase/schema/schema.sql b/supabase/schema/schema.sql index f85e0d9..f1bbea0 100644 --- a/supabase/schema/schema.sql +++ b/supabase/schema/schema.sql @@ -2429,6 +2429,56 @@ end; $$; +-- +-- Name: admin_create_starter_share_link(uuid, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.admin_create_starter_share_link(p_business uuid, p_days integer DEFAULT 365) RETURNS TABLE(token character varying, expires_at timestamp with time zone) + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public' + AS $$ +declare + v_token varchar(32); + v_exp timestamptz; +begin + if not app.is_admin() then + raise exception 'forbidden' using errcode = '42501'; + end if; + if p_days < 1 or p_days > 3650 then + raise exception 'days 1..3650'; + end if; + -- 발급 명단 = 승인 업체 + 판매/생산 허가 승인(0028 §1.3) + if not exists (select 1 from public.business_profiles b + where b.user_id = p_business and b.status = 'approved') then + raise exception 'business_not_approved'; + end if; + if not exists (select 1 from app.business_licenses l + where l.user_id = p_business + and l.license_type in ('sales', 'production') + and l.status = 'approved') then + raise exception 'starter_license_required'; + end if; + + -- 유효(미회수·미만료) 링크 재사용 + select l.token, l.expires_at into v_token, v_exp + from app.share_links l + where l.kind = 'starter' and l.ref_id = p_business + and l.revoked_at is null and l.expires_at > now() + order by l.created_at desc limit 1; + if v_token is not null then + return query select v_token, v_exp; + return; + end if; + + v_token := encode(extensions.gen_random_bytes(16), 'hex'); + v_exp := now() + make_interval(days => p_days); + insert into app.share_links (token, kind, ref_id, created_by, expires_at) + values (v_token, 'starter', p_business, app.uid(), v_exp); + return query select v_token, v_exp; +end; +$$; + + -- -- Name: admin_dashboard_stats(); Type: FUNCTION; Schema: public; Owner: - -- @@ -4648,6 +4698,33 @@ CREATE FUNCTION public.my_received_care_reports(p_limit integer DEFAULT 30, p_of $$; +-- +-- Name: my_vaccination_events(uuid); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.my_vaccination_events(p_pet uuid) RETURNS TABLE(id uuid, label text, due_date date, done_at timestamp with time zone) + 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 public.pet_guardians g + where g.pet_id = p_pet and g.user_id = v_uid) then + raise exception 'not_guardian' using errcode = 'P0001'; + end if; + return query + select e.id, e.label::text, e.due_date, e.done_at + from app.vaccination_events e + where e.pet_id = p_pet + order by e.due_date, e.created_at; +end; +$$; + + -- -- Name: naver_facility_id(text, text); Type: FUNCTION; Schema: public; Owner: - -- @@ -5239,6 +5316,88 @@ end; $$; +-- +-- Name: set_vaccination_done(uuid, boolean); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.set_vaccination_done(p_event uuid, p_done boolean DEFAULT true) RETURNS boolean + LANGUAGE plpgsql 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; + update app.vaccination_events e + set done_at = case when p_done then now() else null end + where e.id = p_event + and exists (select 1 from public.pet_guardians g + where g.pet_id = e.pet_id and g.user_id = v_uid); + return found; +end; +$$; + + +-- +-- Name: set_vaccination_schedule(uuid, jsonb, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.set_vaccination_schedule(p_pet uuid, p_events jsonb, p_source text DEFAULT NULL::text) RETURNS integer + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $_$ +declare + v_uid uuid := app.uid(); + v_cnt integer := 0; + e jsonb; + v_label text; + v_due date; +begin + if v_uid is null then + raise exception 'auth required' using errcode = '42501'; + end if; + if not exists (select 1 from public.pet_guardians g + where g.pet_id = p_pet and g.user_id = v_uid) then + raise exception 'not_guardian' using errcode = 'P0001'; + end if; + if jsonb_typeof(coalesce(p_events, '[]'::jsonb)) is distinct from 'array' + or jsonb_array_length(coalesce(p_events, '[]'::jsonb)) > 40 then + raise exception 'invalid_events' using errcode = 'P0001'; + end if; + if p_source is not null and p_source not in ('onboarding', 'manage') then + raise exception 'invalid_source' using errcode = 'P0001'; + end if; + + delete from app.vaccination_events + where pet_id = p_pet and done_at is null; + + for e in select value from jsonb_array_elements(coalesce(p_events, '[]'::jsonb)) + loop + v_label := nullif(btrim(coalesce(e->>'label', '')), ''); + if v_label is null or length(v_label) > 60 + or coalesce(e->>'due_date', '') !~ '^\d{4}-\d{2}-\d{2}$' then + raise exception 'invalid_events' using errcode = 'P0001'; + end if; + v_due := (e->>'due_date')::date; + if v_due not between current_date - 730 and current_date + 1095 then + raise exception 'invalid_events' using errcode = 'P0001'; + end if; + insert into app.vaccination_events (pet_id, label, due_date, created_by) + values (p_pet, v_label, v_due, v_uid); + v_cnt := v_cnt + 1; + end loop; + + insert into app.funnel_events (event, user_id, props) + values ('vaccine_schedule', v_uid, + jsonb_build_object('pet_id', p_pet, 'count', v_cnt, + 'source', p_source)); + return v_cnt; +end; +$_$; + + -- -- Name: share_view_click(character varying); Type: FUNCTION; Schema: public; Owner: - -- @@ -5335,6 +5494,20 @@ begin return coalesce(v_out, jsonb_build_object('status', 'not_found')); end if; + if v_link.kind = 'starter' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'starter', jsonb_build_object( + 'business_name', coalesce(b.storefront_name, b.business_name))) + into v_out + from public.business_profiles b + where b.user_id = v_link.ref_id and b.status = 'approved'; + -- 업체가 인증 취소돼도 랜딩 자체는 살아있게(출처 표기만 생략) + return coalesce(v_out, jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'starter', jsonb_build_object('business_name', null))); + end if; + return jsonb_build_object('status', 'ok', 'kind', v_link.kind); end; $$; @@ -5963,7 +6136,7 @@ CREATE TABLE app.share_links ( view_count integer DEFAULT 0 NOT NULL, revoked_at timestamp with time zone, created_at timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT share_links_kind_check CHECK (((kind)::text = ANY ((ARRAY['facility_preview'::character varying, 'care_report'::character varying])::text[]))) + CONSTRAINT share_links_kind_check CHECK (((kind)::text = ANY ((ARRAY['facility_preview'::character varying, 'care_report'::character varying, 'starter'::character varying])::text[]))) ); @@ -5974,6 +6147,29 @@ CREATE TABLE app.share_links ( COMMENT ON TABLE app.share_links IS '설치 전 가치 전달용 공유 링크(0028 §3). share-view Edge Function 이 서빙.'; +-- +-- Name: vaccination_events; Type: TABLE; Schema: app; Owner: - +-- + +CREATE TABLE app.vaccination_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + pet_id uuid NOT NULL, + label character varying(60) NOT NULL, + due_date date NOT NULL, + done_at timestamp with time zone, + notified_at timestamp with time zone, + created_by uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: TABLE vaccination_events; Type: COMMENT; Schema: app; Owner: - +-- + +COMMENT ON TABLE app.vaccination_events IS '분양 스타터 접종 리마인더(0028 §5) — 일정 콘텐츠는 앱, 서버는 저장·알림만.'; + + -- -- Name: withdrawn_users; Type: TABLE; Schema: app; Owner: - -- @@ -6414,11 +6610,11 @@ CREATE TABLE public.notifications ( push_attempts smallint DEFAULT 0 NOT NULL, push_error text, CONSTRAINT notifications_aggregated_count_check CHECK ((aggregated_count >= 1)), - CONSTRAINT notifications_notification_type_check CHECK (((notification_type)::text = ANY (ARRAY['chat_message'::text, 'post_application'::text, 'post_comment'::text, 'pawing_new_post'::text, 'application_accepted'::text, 'application_accepted_by_co'::text, 'review_received'::text, 'guardian_invite'::text, 'system_notice'::text, 'location_expired'::text, 'chat_read_receipt'::text, 'unread_sync'::text, 'security_login'::text, 'schedule_changed'::text, 'business_approved'::text, 'business_rejected'::text, 'review_comment'::text, 'post_heart'::text, 'pawing_follow'::text, 'facility_review_received'::text, 'pet_in_post'::text]))), + CONSTRAINT notifications_notification_type_check CHECK (((notification_type)::text = ANY (ARRAY['chat_message'::text, 'post_application'::text, 'post_comment'::text, 'pawing_new_post'::text, 'application_accepted'::text, 'application_accepted_by_co'::text, 'review_received'::text, 'guardian_invite'::text, 'system_notice'::text, 'location_expired'::text, 'chat_read_receipt'::text, 'unread_sync'::text, 'security_login'::text, 'schedule_changed'::text, 'business_approved'::text, 'business_rejected'::text, 'review_comment'::text, 'post_heart'::text, 'pawing_follow'::text, 'facility_review_received'::text, 'pet_in_post'::text, 'vaccine_reminder'::text]))), CONSTRAINT notifications_priority_check CHECK (((priority)::text = ANY ((ARRAY['high'::character varying, 'normal'::character varying, 'low'::character varying])::text[]))), CONSTRAINT notifications_push_attempts_check CHECK ((push_attempts >= 0)), CONSTRAINT notifications_push_status_check CHECK (((push_status)::text = ANY (ARRAY['pending'::text, 'sending'::text, 'sent'::text, 'failed'::text, 'skipped'::text]))), - CONSTRAINT notifications_resource_type_check CHECK (((resource_type IS NULL) OR ((resource_type)::text = ANY (ARRAY['post'::text, 'comment'::text, 'chat_room'::text, 'appointment'::text, 'facility_review'::text, 'user'::text])))) + CONSTRAINT notifications_resource_type_check CHECK (((resource_type IS NULL) OR ((resource_type)::text = ANY (ARRAY['post'::text, 'comment'::text, 'chat_room'::text, 'appointment'::text, 'facility_review'::text, 'user'::text, 'pet'::text])))) ); @@ -7338,6 +7534,14 @@ ALTER TABLE ONLY app.share_links ADD CONSTRAINT share_links_pkey PRIMARY KEY (token); +-- +-- Name: vaccination_events vaccination_events_pkey; Type: CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.vaccination_events + ADD CONSTRAINT vaccination_events_pkey PRIMARY KEY (id); + + -- -- Name: withdrawn_users withdrawn_users_pkey; Type: CONSTRAINT; Schema: app; Owner: - -- @@ -7867,6 +8071,20 @@ CREATE INDEX refresh_tokens_user_idx ON app.refresh_tokens USING btree (user_id) CREATE INDEX share_links_ref_idx ON app.share_links USING btree (kind, ref_id); +-- +-- Name: vaccination_events_due_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX vaccination_events_due_idx ON app.vaccination_events USING btree (due_date) WHERE ((done_at IS NULL) AND (notified_at IS NULL)); + + +-- +-- Name: vaccination_events_pet_idx; Type: INDEX; Schema: app; Owner: - +-- + +CREATE INDEX vaccination_events_pet_idx ON app.vaccination_events USING btree (pet_id, due_date); + + -- -- Name: admin_logs_admin_idx; Type: INDEX; Schema: public; Owner: - -- @@ -8970,6 +9188,22 @@ ALTER TABLE ONLY app.share_links ADD CONSTRAINT share_links_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE CASCADE; +-- +-- Name: vaccination_events vaccination_events_created_by_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.vaccination_events + ADD CONSTRAINT vaccination_events_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: vaccination_events vaccination_events_pet_id_fkey; Type: FK CONSTRAINT; Schema: app; Owner: - +-- + +ALTER TABLE ONLY app.vaccination_events + ADD CONSTRAINT vaccination_events_pet_id_fkey FOREIGN KEY (pet_id) REFERENCES public.pets(id) ON DELETE CASCADE; + + -- -- Name: admin_logs admin_logs_admin_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10395,6 +10629,15 @@ GRANT ALL ON FUNCTION public.admin_create_facility_share_link(p_facility uuid, p GRANT ALL ON FUNCTION public.admin_create_facility_share_link(p_facility uuid, p_days integer) TO service_role; +-- +-- Name: FUNCTION admin_create_starter_share_link(p_business uuid, p_days integer); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.admin_create_starter_share_link(p_business uuid, p_days integer) FROM PUBLIC; +GRANT ALL ON FUNCTION public.admin_create_starter_share_link(p_business uuid, p_days integer) TO authenticated; +GRANT ALL ON FUNCTION public.admin_create_starter_share_link(p_business uuid, p_days integer) TO service_role; + + -- -- Name: FUNCTION admin_dashboard_stats(); Type: ACL; Schema: public; Owner: - -- @@ -10926,6 +11169,15 @@ GRANT ALL ON FUNCTION public.my_received_care_reports(p_limit integer, p_offset GRANT ALL ON FUNCTION public.my_received_care_reports(p_limit integer, p_offset integer) TO service_role; +-- +-- Name: FUNCTION my_vaccination_events(p_pet uuid); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.my_vaccination_events(p_pet uuid) FROM PUBLIC; +GRANT ALL ON FUNCTION public.my_vaccination_events(p_pet uuid) TO authenticated; +GRANT ALL ON FUNCTION public.my_vaccination_events(p_pet uuid) TO service_role; + + -- -- Name: FUNCTION naver_facility_id(p_name text, p_address text); Type: ACL; Schema: public; Owner: - -- @@ -11111,6 +11363,24 @@ REVOKE ALL ON FUNCTION public.set_pet_ai_reference(p_pet uuid, p_verification uu GRANT ALL ON FUNCTION public.set_pet_ai_reference(p_pet uuid, p_verification uuid) TO service_role; +-- +-- Name: FUNCTION set_vaccination_done(p_event uuid, p_done boolean); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.set_vaccination_done(p_event uuid, p_done boolean) FROM PUBLIC; +GRANT ALL ON FUNCTION public.set_vaccination_done(p_event uuid, p_done boolean) TO authenticated; +GRANT ALL ON FUNCTION public.set_vaccination_done(p_event uuid, p_done boolean) TO service_role; + + +-- +-- Name: FUNCTION set_vaccination_schedule(p_pet uuid, p_events jsonb, p_source text); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.set_vaccination_schedule(p_pet uuid, p_events jsonb, p_source text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.set_vaccination_schedule(p_pet uuid, p_events jsonb, p_source text) TO authenticated; +GRANT ALL ON FUNCTION public.set_vaccination_schedule(p_pet uuid, p_events jsonb, p_source text) TO service_role; + + -- -- Name: FUNCTION share_view_click(p_token character varying); Type: ACL; Schema: public; Owner: - -- diff --git a/supabase/tests/t13_starter_p3_test.sql b/supabase/tests/t13_starter_p3_test.sql new file mode 100644 index 0000000..3792dd0 --- /dev/null +++ b/supabase/tests/t13_starter_p3_test.sql @@ -0,0 +1,215 @@ +-- 분양 스타터(0028 P3) — 스타터 QR 발급 게이트·랜딩 데이터·접종 일정·리마인더 스윕. +begin; +set local search_path = public, app, extensions; +\ir helpers/seed.sql +select plan(17); + +create temp table t13 (k text primary key, id uuid not null); + +-- 추가 시드: 관리자. +with u as ( + insert into public.users + (username, password_hash, nickname, user_type, phone, status, active_mode) + values ('t_admin', 'x', '시드관리자', 'admin', '01000000009', 'active', 'personal') + returning id +) +insert into t13 select 'admin', id from u; + +-- ① 관리자 아니면 스타터 링크 발급 불가. +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 * from public.admin_create_starter_share_link( + (select id from seed where k='bizowner'))$$, + '%forbidden%', + '비관리자 스타터 링크 발급 거부' +); + +-- ② 판매/생산 허가 승인 없는 업체는 발급 대상 아님. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from t13 where k='admin'), 'tv', 0)::text, true); +select throws_like( + $$select * from public.admin_create_starter_share_link( + (select id from seed where k='bizowner'))$$, + '%starter_license_required%', + 'sales/production 허가 없는 업체 발급 거부' +); + +-- 추가 시드: sales 허가 승인. +insert into app.business_licenses (user_id, license_type, license_no, document_path, status) +select id, 'sales', 'T-3333', id::text || '/l.png', 'approved' + from seed where k = 'bizowner'; + +-- ③ 발급 성공(32자 hex 토큰). +select lives_ok( + $$select * from public.admin_create_starter_share_link( + (select id from seed where k='bizowner'))$$, + '스타터 링크 발급 성공' +); +select is( + (select count(*)::int from app.share_links + where kind = 'starter' and ref_id = (select id from seed where k='bizowner') + and token ~ '^[0-9a-f]{32}$'), + 1, + '스타터 링크 발급 성공' +); + +-- ④ 재호출은 같은 토큰 재사용(인쇄물 보호). +select is( + (select count(distinct l.token)::int + from public.admin_create_starter_share_link( + (select id from seed where k='bizowner')) l + join app.share_links s on s.token = l.token + where s.kind = 'starter'), + 1, + '유효 링크 재사용' +); +select is( + (select count(*)::int from app.share_links + where kind = 'starter' and ref_id = (select id from seed where k='bizowner')), + 1, + '재호출로 링크가 늘지 않음' +); + +-- ⑤ 뷰어 로드 — kind/업체명 반환. +select is( + (select public.share_view_load( + (select token from app.share_links + where kind = 'starter' + and ref_id = (select id from seed where k='bizowner'))) + #>> '{starter,business_name}'), + '테스트업체', + '스타터 랜딩 데이터(업체명)' +); + +-- ⑥ 접종 일정 — 보호자 아니면 저장 불가. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='friend'), 'tv', 0)::text, true); +select throws_like( + $$select public.set_vaccination_schedule( + (select id from seed where k='pet1'), + '[{"label":"종합백신 1차","due_date":"2026-08-01"}]'::jsonb)$$, + '%not_guardian%', + '비보호자 일정 저장 거부' +); + +-- ⑦ 보호자 저장 성공(3건) + 퍼널 계측. +select set_config('request.jwt.claims', + json_build_object('sub', (select id from seed where k='owner'), 'tv', 0)::text, true); +select is( + public.set_vaccination_schedule( + (select id from seed where k='pet1'), + jsonb_build_array( + jsonb_build_object('label', '종합백신 1차', 'due_date', to_char(current_date + 1, 'YYYY-MM-DD')), + jsonb_build_object('label', '종합백신 2차', 'due_date', to_char(current_date + 15, 'YYYY-MM-DD')), + jsonb_build_object('label', '광견병', 'due_date', to_char(current_date + 60, 'YYYY-MM-DD'))), + 'onboarding'), + 3, + '접종 일정 저장(3건)' +); +select is( + (select count(*)::int from app.funnel_events + where event = 'vaccine_schedule' + and user_id = (select id from seed where k='owner') + and props->>'source' = 'onboarding'), + 1, + '일정 저장 퍼널 계측' +); + +-- ⑧ 형식 오류는 명시 거부. +select throws_like( + $$select public.set_vaccination_schedule( + (select id from seed where k='pet1'), + '[{"label":"x","due_date":"내일"}]'::jsonb)$$, + '%invalid_events%', + '잘못된 날짜 형식 거부' +); + +-- ⑨ 완료 체크 후 재저장 — 완료분은 보존, 미완료분만 교체. +select ok( + public.set_vaccination_done( + (select id from app.vaccination_events + where pet_id = (select id from seed where k='pet1') + and label = '종합백신 1차')), + '완료 체크' +); +select is( + public.set_vaccination_schedule( + (select id from seed where k='pet1'), + jsonb_build_array( + jsonb_build_object('label', '켄넬코프 1차', 'due_date', to_char(current_date + 30, 'YYYY-MM-DD'))), + 'manage'), + 1, + '재저장(미완료 교체)' +); +select is( + (select count(*)::int from app.vaccination_events + where pet_id = (select id from seed where k='pet1')), + 2, + '완료 1건 보존 + 신규 1건' +); + +-- ⑩ 조회는 보호자 전용, 날짜순 전체 반환. +select is( + (select count(*)::int from public.my_vaccination_events( + (select id from seed where k='pet1'))), + 2, + '보호자 일정 조회' +); + +-- ⑪ 리마인더 스윕(크론 본문과 동일 SQL) — D-1 이내 미완료분을 보호자에게 1회 알림. +update app.vaccination_events + set due_date = current_date + where pet_id = (select id from seed where k='pet1') and done_at is null; +with due as ( + update app.vaccination_events e + set notified_at = now() + where e.done_at is null and e.notified_at is null + and e.due_date <= (now() at time zone 'Asia/Seoul')::date + 1 + returning e.pet_id, e.label, e.due_date +) +insert into public.notifications + (user_id, notification_type, is_system, title, body, resource_type, resource_id) +select g.user_id, 'vaccine_reminder', true, + p.name || ' 접종일이 다가와요', + d.label || ' — ' || + case when d.due_date <= (now() at time zone 'Asia/Seoul')::date + then '오늘' else '내일' end || ' 예정이에요. 병원 일정을 확인해 주세요.', + 'pet', d.pet_id + from due d + join public.pets p on p.id = d.pet_id and p.pet_status = 'active' + join public.pet_guardians g on g.pet_id = d.pet_id + where d.due_date >= (now() at time zone 'Asia/Seoul')::date; +select is( + (select count(*)::int from public.notifications + where notification_type = 'vaccine_reminder' + and user_id = (select id from seed where k='owner') + and resource_type = 'pet' + and resource_id = (select id from seed where k='pet1')), + 1, + '접종 리마인더 알림 생성(CHECK 통과 포함)' +); + +-- ⑫ 스윕 재실행 — notified_at 잠금으로 중복 알림 없음. +with due as ( + update app.vaccination_events e + set notified_at = now() + where e.done_at is null and e.notified_at is null + and e.due_date <= (now() at time zone 'Asia/Seoul')::date + 1 + returning e.pet_id, e.label, e.due_date +) +insert into public.notifications + (user_id, notification_type, is_system, title, body, resource_type, resource_id) +select g.user_id, 'vaccine_reminder', true, 'x', 'x', 'pet', d.pet_id + from due d + join public.pet_guardians g on g.pet_id = d.pet_id; +select is( + (select count(*)::int from public.notifications + where notification_type = 'vaccine_reminder' + and user_id = (select id from seed where k='owner')), + 1, + '스윕 재실행 시 중복 알림 없음' +); + +select * from finish(); +rollback; diff --git a/workers/share-proxy/src/index.js b/workers/share-proxy/src/index.js index 753c554..6ed2674 100644 --- a/workers/share-proxy/src/index.js +++ b/workers/share-proxy/src/index.js @@ -9,12 +9,30 @@ // // 라우트 (wrangler.toml custom_domain): // go.pawmate.kr/s?t=[&go=store] → share-view 프록시(HTML 복원) +// */.well-known/* → 유니버설 링크·앱 링크 검증 파일(출시 준비) // pawmate.kr, www.pawmate.kr → 임시 랜딩(출시 전 안내) // ============================================================================ const UPSTREAM = "https://vyatppuxmpulqtxevfpk.supabase.co/functions/v1/share-view"; +// ── 유니버설 링크(iOS) — 설치된 앱이 go.pawmate.kr/s 링크를 바로 열게 한다. +// appID = .. 앱 쪽 associated-domains(applinks:go.pawmate.kr) +// + 링크 수신 라우팅은 출시 준비 체크리스트(docs/launch-checklist.md) 참조. +const AASA = { + applinks: { + apps: [], + details: [{ + appIDs: ["5GVP46ZJ2H.com.seizeh.pawmate"], + components: [{ "/": "/s", comment: "공유 뷰어" }], + }], + }, +}; + +// ── 앱 링크(Android) — 릴리스 서명키 SHA-256 지문 확보 전까지 빈 배열(404 응답). +// 지문 채우면 자동으로 서빙 시작: keytool -list -printcert -keystore +const ANDROID_CERT_SHA256 = []; + export default { async fetch(req) { const url = new URL(req.url); @@ -45,6 +63,32 @@ export default { return new Response(res.body, { status: res.status, headers }); } + // ── 유니버설 링크·앱 링크 검증 파일 — 모든 호스트에서 서빙(무해) ── + if ( + url.pathname === "/.well-known/apple-app-site-association" || + url.pathname === "/apple-app-site-association" + ) { + return new Response(JSON.stringify(AASA), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/.well-known/assetlinks.json") { + if (ANDROID_CERT_SHA256.length === 0) { + return new Response("not configured", { status: 404 }); + } + return new Response( + JSON.stringify([{ + relation: ["delegate_permission/common.handle_all_urls"], + target: { + namespace: "android_app", + package_name: "com.seizeh.pawmate", + sha256_cert_fingerprints: ANDROID_CERT_SHA256, + }, + }]), + { headers: { "content-type": "application/json" } }, + ); + } + // ── 루트/기타 — 출시 전 임시 랜딩 (스토어 오픈 후 리다이렉트로 교체) ── return new Response(LANDING, { status: url.pathname === "/" ? 200 : 404,