Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions supabase/functions/share-view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,67 @@ Deno.serve(async (req) => {
return html(page(`${petLabel}의 ${kindLabel}`, `${bizName ?? "PawMate"} · 케어 리포트`, inner));
}

// ── 게시글 공유 — 앱 게시글 상세 미러(히어로 미디어 → 카테고리 칩 →
// 제목 → 작성자·날짜 → 본문). 영상은 포스터 + <video controls>. ──
if (data.kind === "post") {
const po = data.post ?? {};
const POST_CATEGORY_LABELS: Record<string, string> = {
walk_together: "동반산책",
walk_proxy: "대리산책",
care: "돌봄",
give_away: "분양",
adoption: "입양",
free: "자유",
news: "소식",
};
const title = String(po.title ?? "게시글");
const content = String(po.content ?? "");
const catLabel = POST_CATEGORY_LABELS[String(po.category)] ??
String(po.category ?? "");
const author = po.author_name ? String(po.author_name) : "알 수 없음";
const isVideo = String(po.image_mime ?? "").startsWith("video/");
const mediaUrl = po.image_url ? String(po.image_url) : null;
const thumbUrl = po.image_thumb_url ? String(po.image_thumb_url) : null;
const d = new Date(String(po.created_at ?? ""));
const dateStr = isNaN(d.getTime())
? ""
: `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${
String(d.getDate()).padStart(2, "0")
}`;
// 히어로 — 앱 상세의 미디어 히어로 미러(3:4, radius 24)
const hero = !mediaUrl ? "" : isVideo
? `<div style="margin:0 20px 4px;border-radius:24px;overflow:hidden;aspect-ratio:3/4;background:#000">
<video controls playsinline preload="metadata"
${thumbUrl ? `poster="${esc(thumbUrl)}"` : ""}
src="${esc(mediaUrl)}"
style="width:100%;height:100%;object-fit:cover"></video>
</div>`
: `<div style="margin:0 20px 4px;border-radius:24px;overflow:hidden;aspect-ratio:3/4">
<img src="${esc(mediaUrl)}" alt=""
style="width:100%;height:100%;object-fit:cover" loading="lazy">
</div>`;
const inner = `
${hero}
<div class="pad" style="margin-top:${mediaUrl ? "16" : "4"}px">
<span style="display:inline-block;font-size:11.5px;font-weight:700;
color:var(--primary-dark);background:var(--surface-muted);
border:.5px solid var(--border);border-radius:100px;padding:3px 10px">
${esc(catLabel)}</span>
<div style="font-size:20px;font-weight:800;line-height:1.4;margin-top:10px">
${esc(title)}</div>
<div style="font-size:12.5px;color:var(--text2);margin-top:6px">
${esc(author)}${dateStr ? ` · ${dateStr}` : ""}</div>
</div>
<div class="info-card" style="margin-top:14px">
<div style="font-size:14.5px;line-height:1.7;white-space:pre-wrap">${
esc(content)
}</div>
</div>
<a class="cta" href="?t=${token}&amp;go=store">PawMate 앱에서 이 글 보기</a>
<a class="cta sub" href="?t=${token}&amp;go=store">우리 동네 반려 이웃의 소식이 더 있어요</a>`;
return html(page(title, `${author} · ${catLabel} — PawMate`, inner));
}

// ── 분양 스타터 랜딩(P3) — 본문은 정적 콘텐츠, 업체명만 데이터(출처 표기·계측) ──
if (data.kind === "starter") {
const st = data.starter ?? {};
Expand Down
175 changes: 175 additions & 0 deletions supabase/migrations/20260725100000_post_share.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
-- ============================================================================
-- 게시글 공유 — 모든 카테고리 게시글을 공유 뷰어 링크로 (0028 §3 인프라 재사용)
--
-- · 앱 상세 화면의 공유 버튼 → create_post_share_link → go.pawmate.kr/s?t=…
-- 로그인 없이 열람(설치 전 가치 원칙). 뷰어는 앱 게시글 상세 디자인 미러.
-- · 링크는 게시글당 1개 재사용(유효 링크 존재 시), 만료 30일.
-- · 열람 시점 검증: 게시글이 삭제·숨김이면 not_found — 링크 발급 후 상태가
-- 바뀌어도 노출되지 않는다(발급 시점이 아니라 로드 시점 기준).
-- · 노출 필드 최소: 카테고리·제목·본문·미디어·작성자 표시명(소식은 상호)·
-- 작성일. 좌표·전화·지역코드는 노출하지 않는다.
-- ============================================================================

-- (1) kind 확장
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', 'post'));

-- (2) 공유 링크 발급 — 로그인 사용자 누구나(타인 글 공유도 일반 패턴).
create or replace function public.create_post_share_link(p_post uuid)
returns table (token varchar, expires_at timestamptz)
language plpgsql
security definer
set search_path to ''
as $function$
declare
v_uid uuid := app.uid();
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 exists (select 1 from public.posts p
where p.id = p_post and p.visibility_status = 'visible') then
raise exception 'post_not_found' using errcode = 'P0001';
end if;

select l.token, l.expires_at into v_token, v_exp
from app.share_links l
where l.kind = 'post' and l.ref_id = p_post
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() + interval '30 days';
insert into app.share_links (token, kind, ref_id, created_by, expires_at)
values (v_token, 'post', p_post, v_uid, v_exp);
insert into app.funnel_events (event, token, user_id)
values ('post_share', v_token, v_uid);
return query select v_token, v_exp;
end;
$function$;
revoke all on function public.create_post_share_link(uuid) from public;
grant execute on function public.create_post_share_link(uuid) to authenticated;

-- (3) 뷰어 — post 분기(로드 시점 가시성 검증, 소식은 상호 표기)
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,
'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(
'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$;
74 changes: 73 additions & 1 deletion supabase/schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4041,6 +4041,48 @@ end;
$$;


--
-- Name: create_post_share_link(uuid); Type: FUNCTION; Schema: public; Owner: -
--

CREATE FUNCTION public.create_post_share_link(p_post uuid) RETURNS TABLE(token character varying, expires_at timestamp with time zone)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO ''
AS $$
declare
v_uid uuid := app.uid();
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 exists (select 1 from public.posts p
where p.id = p_post and p.visibility_status = 'visible') then
raise exception 'post_not_found' using errcode = 'P0001';
end if;

select l.token, l.expires_at into v_token, v_exp
from app.share_links l
where l.kind = 'post' and l.ref_id = p_post
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() + interval '30 days';
insert into app.share_links (token, kind, ref_id, created_by, expires_at)
values (v_token, 'post', p_post, v_uid, v_exp);
insert into app.funnel_events (event, token, user_id)
values ('post_share', v_token, v_uid);
return query select v_token, v_exp;
end;
$$;


--
-- Name: create_post_verified(character varying, character varying, text, timestamp with time zone, uuid[], text, character varying, integer, uuid, double precision, double precision, character varying, text); Type: FUNCTION; Schema: public; Owner: -
--
Expand Down Expand Up @@ -5529,6 +5571,27 @@ begin
'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(
'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;
$$;
Expand Down Expand Up @@ -6160,7 +6223,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, 'starter'::character varying])::text[])))
CONSTRAINT share_links_kind_check CHECK (((kind)::text = ANY ((ARRAY['facility_preview'::character varying, 'care_report'::character varying, 'starter'::character varying, 'post'::character varying])::text[])))
);


Expand Down Expand Up @@ -11011,6 +11074,15 @@ GRANT ALL ON FUNCTION public.create_care_thread(p_pet_label text, p_recipient_ph
GRANT ALL ON FUNCTION public.create_care_thread(p_pet_label text, p_recipient_phone text) TO service_role;


--
-- Name: FUNCTION create_post_share_link(p_post uuid); Type: ACL; Schema: public; Owner: -
--

REVOKE ALL ON FUNCTION public.create_post_share_link(p_post uuid) FROM PUBLIC;
GRANT ALL ON FUNCTION public.create_post_share_link(p_post uuid) TO authenticated;
GRANT ALL ON FUNCTION public.create_post_share_link(p_post uuid) 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, p_image_thumb_url text); Type: ACL; Schema: public; Owner: -
--
Expand Down
Loading
Loading