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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- 시설 데이터 운영 장치 두 가지 — 0033 §8 "남은 것" 의 사람 의존을 장치로 바꾼다.
--
-- ① 월 1회 재적재 리마인더 (cron)
--
-- 적재 파이프라인에는 cron 이 없다 — 함수가 CSV 를 직접 받지 않는 구조라(0033
-- §4.2) 사람이 파일을 받아 스크립트를 돌려야 한다. 그런데 이번 사고의 본질이
-- "잊혀서 6주 동결" 이었다. 잊지 않게 하는 장치까지가 파이프라인이다.
-- 매월 1일 09:00 KST 에 ops_alarm_fire → 관리자 전원 알림(→ 푸시).
--
-- ② 이름 동결(owner_updated_at) 행의 주소 변경 감지 (trigger)
--
-- 임시 개명 행(0033 §8): 이름은 owner_updated_at 으로 얼렸지만 **주소는 못
-- 얼린다** — 주소는 인허가 데이터 영역이라 재적재가 항상 덮는다(의도된 정책,
-- 20260715130000). 원 매장의 이전이 LOCALDATA 에 반영되는 순간 "새 가게 이름 +
-- 옮겨간 주소" 라는 어긋난 행이 되는데, 지금은 재적재 때마다 사람이 기억해서
-- 확인해야 한다. 어긋나는 그 UPDATE 순간에 알리면 기억할 필요가 없다.
--
-- 특정 행 id 를 하드코딩하지 않는다 — owner_updated_at 을 가진 행 전부를 본다.
-- 그 행들은 전부 "간판명·전화가 동결된 행" 이고, 동결된 이름 아래에서 주소가
-- 움직이면 어느 행이든 사람이 봐야 한다. 같은 성격의 행이 늘어도 그대로 커버된다.
--
-- 알람 실패가 재적재를 막으면 안 된다 — 이 트리거는 upsert_facilities 배치 안에서
-- 돈다. trg_reports_alert(20260831061325)와 같은 예외 삼킴 패턴을 쓴다.

-- ── ② 동결 행 주소 변경 감지 ────────────────────────────────────────────

create function app.tg_facilities_frozen_addr_alert()
returns trigger
language plpgsql
security definer
set search_path to ''
as $$
begin
perform app.ops_alarm_fire(
'facility_frozen_addr:' || new.id,
1440, -- 행별 하루 쿨다운 — 같은 배치를 다시 돌려도 하루 한 번만.
'이름 동결 시설의 주소가 바뀜',
format('%s — 재적재가 주소를 덮었습니다. 새 가게 분리 등록/이전 처리가 필요한지 확인하세요(0033 §8).', new.name),
jsonb_build_object(
'facility_id', new.id,
'name', new.name,
'old_address', old.address,
'new_address', new.address
)
);
return new;
exception when others then
-- 알림 실패가 적재 배치 자체를 막으면 안 된다.
raise warning 'tg_facilities_frozen_addr_alert failed: %', sqlerrm;
return new;
end $$;

comment on function app.tg_facilities_frozen_addr_alert() is
'이름 동결(owner_updated_at) 시설의 주소가 재적재로 덮이는 순간 관리자에게 알린다(0033 §8 임시 개명 행).';

create trigger trg_facilities_frozen_addr_alert
after update on public.facilities
for each row
when (new.owner_updated_at is not null
and new.address is distinct from old.address)
execute function app.tg_facilities_frozen_addr_alert();

comment on trigger trg_facilities_frozen_addr_alert on public.facilities is
'동결 행 주소 변경 감지 — 재적재 때마다 사람이 확인하던 것을 UPDATE 시점 알림으로 대체.';

-- ── ① 월간 재적재 리마인더 ──────────────────────────────────────────────
-- 매월 1일 00:00 UTC = 09:00 KST. 쿨다운 1일 — 수동 발화 시험과 겹쳐도 중복 없음.

do $$ begin
if exists (select 1 from cron.job where jobname = 'facility-resync-reminder') then
perform cron.unschedule('facility-resync-reminder');
end if;
end $$;
select cron.schedule('facility-resync-reminder', '0 0 1 * *', $$
select app.ops_alarm_fire(
'facility_resync_due', 1440,
'시설 데이터 월간 재적재',
'재적재를 돌릴 때입니다 — 0033 §7 절차대로: CSV 4종(동물판매업 포함)·Excel 금지·최종수정시점 확인·--dry-run 먼저. 폐업·휴업 0건 파일은 필터본이니 쓰지 말 것.',
jsonb_build_object('doc', '0033 §7')
)
$$);
1 change: 1 addition & 0 deletions supabase/schema/outofband.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ CRON auth-cleanup [17 * * * *] active=true :: select app.cleanup_auth();
CRON business-docs-purge [13 4 * * *] active=true :: select net.http_post( url := (select function_url from app.business_purge_config), headers := jsonb_build_object( 'Content-Type', 'application/json', 'x-purge-secret', (select trigger_secret from app.business_purge_config)), body := '{}'::jsonb );
CRON care-report-hmac-purge [58 3 * * *] active=true :: 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'
CRON engagement-sweep [* * * * *] active=true :: select app.dispatch_engagement_notifications();
CRON facility-resync-reminder [0 0 1 * *] active=true :: select app.ops_alarm_fire( 'facility_resync_due', 1440, '시설 데이터 월간 재적재', '재적재를 돌릴 때입니다 — 0033 §7 절차대로: CSV 4종(동물판매업 포함)·Excel 금지·최종수정시점 확인·--dry-run 먼저. 폐업·휴업 0건 파일은 필터본이니 쓰지 말 것.', jsonb_build_object('doc', '0033 §7') )
CRON funnel-events-retention [53 3 * * *] active=true :: delete from app.funnel_events where created_at < now() - interval '1 year'
CRON ops-alarm-sweep [*/5 * * * *] active=true :: select app.ops_alarm_sweep();
CRON push-sweep [* * * * *] active=true :: select net.http_post( url := (select function_url from app.push_config), headers := jsonb_build_object('Content-Type','application/json','x-push-secret',(select trigger_secret from app.push_config)), body := '{}'::jsonb) where exists (select 1 from public.notifications where push_status = 'pending');
Expand Down
50 changes: 50 additions & 0 deletions supabase/schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,42 @@ end;
$$;


--
-- Name: tg_facilities_frozen_addr_alert(); Type: FUNCTION; Schema: app; Owner: -
--

CREATE FUNCTION app.tg_facilities_frozen_addr_alert() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO ''
AS $$
begin
perform app.ops_alarm_fire(
'facility_frozen_addr:' || new.id,
1440, -- 행별 하루 쿨다운 — 같은 배치를 다시 돌려도 하루 한 번만.
'이름 동결 시설의 주소가 바뀜',
format('%s — 재적재가 주소를 덮었습니다. 새 가게 분리 등록/이전 처리가 필요한지 확인하세요(0033 §8).', new.name),
jsonb_build_object(
'facility_id', new.id,
'name', new.name,
'old_address', old.address,
'new_address', new.address
)
);
return new;
exception when others then
-- 알림 실패가 적재 배치 자체를 막으면 안 된다.
raise warning 'tg_facilities_frozen_addr_alert failed: %', sqlerrm;
return new;
end $$;


--
-- Name: FUNCTION tg_facilities_frozen_addr_alert(); Type: COMMENT; Schema: app; Owner: -
--

COMMENT ON FUNCTION app.tg_facilities_frozen_addr_alert() IS '이름 동결(owner_updated_at) 시설의 주소가 재적재로 덮이는 순간 관리자에게 알린다(0033 §8 임시 개명 행).';


--
-- Name: tg_facility_review_aggs(); Type: FUNCTION; Schema: app; Owner: -
--
Expand Down Expand Up @@ -10305,6 +10341,20 @@ CREATE TRIGGER trg_comments_soft_delete_ts BEFORE UPDATE ON public.comments FOR
CREATE TRIGGER trg_device_tokens_updated BEFORE UPDATE ON public.device_tokens FOR EACH ROW EXECUTE FUNCTION app.tg_set_updated_at();


--
-- Name: facilities trg_facilities_frozen_addr_alert; Type: TRIGGER; Schema: public; Owner: -
--

CREATE TRIGGER trg_facilities_frozen_addr_alert AFTER UPDATE ON public.facilities FOR EACH ROW WHEN (((new.owner_updated_at IS NOT NULL) AND (new.address IS DISTINCT FROM old.address))) EXECUTE FUNCTION app.tg_facilities_frozen_addr_alert();


--
-- Name: TRIGGER trg_facilities_frozen_addr_alert ON facilities; Type: COMMENT; Schema: public; Owner: -
--

COMMENT ON TRIGGER trg_facilities_frozen_addr_alert ON public.facilities IS '동결 행 주소 변경 감지 — 재적재 때마다 사람이 확인하던 것을 UPDATE 시점 알림으로 대체.';


--
-- Name: facility_reviews trg_facility_review_recall; Type: TRIGGER; Schema: public; Owner: -
--
Expand Down
72 changes: 72 additions & 0 deletions supabase/tests/t26_facility_frozen_addr_test.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- 동결 행 주소 변경 감지 — 사람 기억을 대체하는 장치가 실제로 울리는가.
--
-- 이 트리거의 실패 방식도 t21 과 같다: "틀린 알람" 이 아니라 **"아무 알람도 안 옴"**.
-- 재적재는 한 달에 한 번이고 어긋나는 행은 한 개라, 안 울려도 아무도 모른다.
-- 그래서 울려야 할 때(동결 행 주소 변경)와 울리면 안 될 때(일반 행·주소 무변경)를
-- 함께 못 박는다.
begin;
set local search_path = public, app, extensions;
\ir helpers/seed.sql
select plan(6);

-- 관리자 한 명 — 알람 수신자(ops_alarm_fire 가 notifications 를 만든다).
with u as (
insert into public.users (username, password_hash, nickname, user_type, status)
values ('t_admin26', 'x', '시드관리자', 'admin', 'active')
returning id
) insert into seed select 'admin', id from u;

-- 시설 두 행: 동결(owner_updated_at 有) / 일반.
insert into public.facilities (category, source, ext_id, name, address, owner_updated_at)
values ('grooming', 'test', 't26-frozen', '동결샵', '옛 주소 1', now());
insert into public.facilities (category, source, ext_id, name, address)
values ('grooming', 'test', 't26-normal', '일반샵', '옛 주소 2');

-- ── 1. 동결 행의 주소가 바뀌면 울린다 ─────────────────────────────────────
update public.facilities set address = '새 주소 1' where ext_id = 't26-frozen';

select is(
(select count(*)::int from app.ops_alarms
where alarm_key = 'facility_frozen_addr:'
|| (select id from public.facilities where ext_id = 't26-frozen')),
1, '동결 행 주소 변경은 알람 1건');
select is(
(select count(*)::int from public.notifications n
where n.user_id = (select id from seed where k = 'admin')
and n.title = '이름 동결 시설의 주소가 바뀜'),
1, '활성 관리자에게 알림이 간다');
select is(
(select (a.detail->>'old_address') || '→' || (a.detail->>'new_address')
from app.ops_alarms a
where a.alarm_key like 'facility_frozen_addr:%' limit 1),
'옛 주소 1→새 주소 1', 'detail 에 변경 전후 주소가 남는다 — 알람만 보고 판단할 수 있어야 한다');

-- ── 2. 쿨다운 — 같은 행을 하루 안에 또 덮어도 중복으로 울리지 않는다 ──────
update public.facilities set address = '새 주소 1-2' where ext_id = 't26-frozen';
select is(
(select count(*)::int from app.ops_alarms where alarm_key like 'facility_frozen_addr:%'),
1, '행별 쿨다운(1일) 안의 재변경은 접힌다');

-- ── 3. 울리면 안 될 때 ────────────────────────────────────────────────────
update public.facilities set address = '새 주소 2' where ext_id = 't26-normal';
update public.facilities set phone = '0311234567' where ext_id = 't26-frozen';
select is(
(select count(*)::int from app.ops_alarms where alarm_key like 'facility_frozen_addr:%'),
1, '일반 행 주소 변경·동결 행 주소 외 변경은 울리지 않는다');

-- ── 4. 알람 실패가 적재를 막지 않는다 ─────────────────────────────────────
-- ops_alarms 에 같은 트랜잭션이 못 넣게 만들 방법이 마땅치 않으니, 함수를 직접
-- 깨진 인자로 불러 예외 삼킴을 검증하는 대신 **트리거 함수의 예외 절이 UPDATE 를
-- 살리는지**를 본다: ops_alarm_fire 를 일시적으로 실패하게 바꾼다.
create or replace function app.ops_alarm_fire(
p_key text, p_cooldown_min integer, p_title text, p_body text, p_detail jsonb)
returns integer language plpgsql security definer set search_path to '' as
$t26$ begin raise exception 't26: 강제 실패'; end $t26$;

update public.facilities set address = '새 주소 1-3' where ext_id = 't26-frozen';
select is(
(select address from public.facilities where ext_id = 't26-frozen'),
'새 주소 1-3', '알람이 죽어도 적재(UPDATE)는 산다 — 예외 삼킴');

select * from finish();
rollback;
Loading