From 22d4d28ac79a43aab7733728e69e0ed18127041f Mon Sep 17 00:00:00 2001 From: seizeh Date: Sun, 26 Jul 2026 14:50:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=EC=97=90=20'=EC=9B=B9=EC=97=90=EC=84=9C=20=EA=B3=84=EC=86=8D?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0'=20CTA=20=E2=80=94=20=EC=9B=B9=EC=95=B1?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 웹 버전(pmdart docs/web-port.md)으로 잇는 2차 동선. share-view 페이지 자체는 그대로 둔다 — 웹앱으로 대체하면 OG 링크 미리보기(크롤러가 빈 셸을 받음), 즉시 페인트(JS 0 → CanvasKit 2MB), 퍼널 계측을 모두 잃는다. 1차 CTA(스토어)도 그대로고, 게시글 공유에만 붙인다(케어리포트·업체 미리보기는 웹앱에 대응 화면이 없다 — 시설 상세는 지도 딸림, 지도는 웹 범위 밖). · share_view_load 의 post 분기에 'id' 추가. 웹앱은 anon 이라 이 함수를 못 부르고 (service_role 전용), 새 anon RPC 를 파는 대신 이미 서버가 아는 post id 를 응답에 실어 Edge Function 이 CTA 주소를 만든다. 권한 변경 없음. 노출 확대도 아니다 — 게시글은 v_post_feed 로 이미 anon 열람 가능하다. · 이 함수는 마이그레이션 5개가 나눠 고쳐왔다. 위 정의는 과거 파일 합성이 아니라 프로덕션 현재 정의(pg_get_functiondef)에 한 줄만 더한 것이다(병렬 변경 유실 방지). 적용 시점 기준 마이그레이션 파일과 드리프트 없음을 확인했다. · WEB_APP_URL 미설정이면 CTA 를 그리지 않는다 — 웹앱 배포 전 죽은 링크 방지. 적용 순서(웹앱 배포 이후): 1) 마이그레이션 적용 → ./scripts/dump_schema.sh 로 스냅샷 갱신·커밋 2) supabase functions deploy share-view 3) 시크릿 WEB_APP_URL=https://app.pawmate.kr → 이 시점에 CTA 노출 Co-Authored-By: Claude Opus 5 (1M context) --- supabase/functions/share-view/index.ts | 15 ++ .../20260726120000_share_view_post_id.sql | 142 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 supabase/migrations/20260726120000_share_view_post_id.sql diff --git a/supabase/functions/share-view/index.ts b/supabase/functions/share-view/index.ts index 4e08752..13158a7 100644 --- a/supabase/functions/share-view/index.ts +++ b/supabase/functions/share-view/index.ts @@ -28,6 +28,11 @@ const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; // 스토어 링크 — 출시 전엔 미설정(버튼이 준비 중 안내로 대체됨) const STORE_URL_IOS = Deno.env.get("STORE_URL_IOS") ?? ""; const STORE_URL_ANDROID = Deno.env.get("STORE_URL_ANDROID") ?? ""; +// 웹앱 주소(예: https://app.pawmate.kr) — 미설정이면 '웹에서 보기' CTA 를 아예 +// 그리지 않는다. 웹앱 배포 전에 죽은 링크가 노출되지 않게 하는 스위치. +// 게시글 공유에만 쓴다 — 케어리포트·업체 미리보기는 웹앱에 대응 화면이 없다 +// (지도·시설 상세는 웹 범위 밖. pmdart docs/web-port.md 결정 2). +const WEB_APP_URL = (Deno.env.get("WEB_APP_URL") ?? "").replace(/\/+$/, ""); // 카테고리 라벨 — 앱과 동일. const CATEGORY_LABELS: Record = { @@ -161,6 +166,15 @@ function page(title: string, ogDesc: string, inner: string): string {
PawMate
${inner}
`; } +/// '웹에서 계속 보기' CTA — 웹앱 주소가 설정됐고 게시글 id 가 있을 때만. +/// 앱 설치 없이 브라우저에서 계속 둘러보게 하는 2차 동선이다(1차는 스토어). +/// 계측은 기존 share_view 이벤트가 이미 이 페이지 열람 시점에 기록한다. +function webCta(postId: unknown): string { + if (!WEB_APP_URL || typeof postId !== "string" || !postId) return ""; + return `웹에서 계속 보기`; +} + function noticePage(title: string, msg: string, status: number): Response { return html( page(title, msg, `

${esc(title)}

${esc(msg)}

`), @@ -317,6 +331,7 @@ Deno.serve(async (req) => { } PawMate 앱에서 이 글 보기 + ${webCta(po.id)} 우리 동네 반려 이웃의 소식이 더 있어요`; return html(page(title, `${author} · ${catLabel} — PawMate`, inner)); } diff --git a/supabase/migrations/20260726120000_share_view_post_id.sql b/supabase/migrations/20260726120000_share_view_post_id.sql new file mode 100644 index 0000000..e5250a0 --- /dev/null +++ b/supabase/migrations/20260726120000_share_view_post_id.sql @@ -0,0 +1,142 @@ +-- ============================================================================ +-- 공유 뷰어 → 웹앱 연결: post 분기에 게시글 id 노출 +-- +-- · 배경: 공유 링크(go.pawmate.kr/s)에 "웹에서 계속 보기" CTA 를 붙여 웹앱 +-- (app.pawmate.kr/p/)으로 잇는다. 서버 렌더링 미리보기·OG 태그· +-- 퍼널 계측은 그대로 두고 동선만 추가한다(pmdart docs/web-port.md 결정 6). +-- · share_view_load 는 service_role 전용이라 웹앱(anon)이 토큰을 못 읽는다. +-- 새 anon RPC 를 파는 대신 **이미 서버가 아는 post id 를 응답에 싣는다** — +-- Edge Function 이 그 id 로 CTA 주소를 만든다. 권한 변경 없음. +-- · 노출 확대가 아니다: 게시글 자체는 v_post_feed 로 이미 anon 열람 가능하고 +-- (비로그인 둘러보기), id 는 그 뷰의 키다. 좌표·전화·지역코드는 여전히 미노출. +-- +-- ⚠️ 이 함수는 여러 마이그레이션이 나눠 고쳐 왔다(hero·photo_first· +-- owner_verified·review_photos·post_share). 아래 정의는 **적용 시점의 +-- 프로덕션 현재 정의**(pg_get_functiondef)에 'id' 한 줄만 더한 것이다 — +-- 과거 마이그레이션을 합성하지 말 것(병렬 변경 유실). +-- +-- 적용 후: ./scripts/dump_schema.sh 로 스키마 스냅샷 갱신 + 커밋. +-- ============================================================================ + +create or replace function public.share_view_load(p_token character varying) +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, + 'videos', r.videos) + order by r.has_media desc, r.created_at desc) + from (select rating, content, has_incentive, created_at, videos, + coalesce(array_length(photo_urls, 1), 0) > 0 + or jsonb_array_length(videos) > 0 as has_media, + (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 + or jsonb_array_length(videos) > 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; + + if v_link.kind = 'post' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'post', jsonb_build_object( + -- id: 웹앱 CTA(app.pawmate.kr/p/) 주소 생성용 — 이번 변경의 전부. + 'id', p.id, + 'category', p.category, 'title', p.title, 'content', p.content, + 'image_url', p.image_url, 'image_mime', p.image_mime_type, + 'image_thumb_url', p.image_thumbnail_url, + 'created_at', p.created_at, + 'author_name', case + when p.category = 'news' + then coalesce(b.storefront_name, b.business_name, pr.nickname) + else pr.nickname end)) + into v_out + from public.posts p + left join public.public_profiles pr on pr.id = p.user_id + left join public.business_profiles b + on p.category = 'news' and b.user_id = p.user_id + where p.id = v_link.ref_id and p.visibility_status = 'visible'; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + + return jsonb_build_object('status', 'ok', 'kind', v_link.kind); +end; +$function$; + +-- 권한은 기존과 동일(변경 없음) — 명시적으로 재확인만 한다. +revoke all on function public.share_view_load(varchar) from public; +revoke execute on function public.share_view_load(varchar) from anon, authenticated; +grant execute on function public.share_view_load(varchar) to service_role;