* feat(parties): phase 1 substrate, one party per counterpart Adds the identity layer above customers and suppliers, which keep their tables and every foreign key and gain a nullable party_id. - parties: company-scoped identity with status (suggested | confirmed), kind, alias keys, origin and merged_into. One live party per org number and company, enforced by a partial unique index; merged losers leave the index so a merge can be undone. This is the unique key the duplicate-invoice guard has lacked, since suppliers never had one. - party_facts: statements with a source, a rank (preferred | normal | deprecated) and two time axes, never overwritten. - party_identities: bankgiro, plusgiro, IBAN and friends per party, with seen and paid counts and a known | unverified status. - party_decisions: every human action on a party as a labelled example. - normalize_org_number(text): SQL mirror of lib/invariants/org-number.ts (strip separators, drop the century on 12 digits, Luhn check, 10 digits). - ensure_party(): find by org number inside the company, else create. Name-only rows never merge at insert time; a name merge is a recorded human decision. - Backfill: one party per existing supplier and customer, merged on org number, suppliers first so both roles land on one party. - Archive contract: the four tables are master data in the full archive. Observed parties (keys derived from voucher and bank text) are not stored; they stay computed by the ledger-context RPC. No posted entry is touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): observed parties from voucher text, ledger_key and its mirror Migrants arrive with vouchers, not bank transactions, so the bank-keyed ledger context is empty for them. This adds the description-keyed twin. - public.ledger_key(text): legibility key on top of the frozen normalize_counterparty_key mirror: strips AP-register prefixes (levfakt, leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier number that follows them, and trailing 1-3 digit runs, never "inköp". Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared fixture list in the pg test. - public.get_observed_parties(company, from_date, limit): posted vouchers grouped by ledger_key(description) with occurrences, variants, expense and revenue SEK from the lines, first/last seen, median cadence and the Laplace-smoothed dominant result account. Excludes storno, opening balance, year-end and VAT settlement, and vouchers that carry a bank merchant name (those stay with get_ledger_deep_context). SECURITY INVOKER, so RLS scopes it. Never stored. - lib/parties/classify.ts: the deterministic pre-classifier moved out of the evaluation script so product and evaluation share one implementation (0.965 agreement with the founder labels, party recall 0.99). - lib/parties/observed.ts: RPC wrapper that classifies each row and derives a display rhythm from the cadence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): tenant-safe composite foreign keys on every party link Facts, identities, decisions, customers.party_id, suppliers.party_id and parties.merged_into now reference parties(id, company_id), so a row can only point at a party in its own company. ON DELETE SET NULL names party_id so role rows keep their company_id. Adds a pg-real test that rejects every cross-company link and checks company_id survives a party delete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): suggestion pipeline from ledger keys and linked documents Phase 1c of the parties plan. Fills a migrant's register with suggested parties from what the ledger already knows, never as facts: - get_ledger_key_evidence(company): hard keys per ledger_key from the documents linked to posted vouchers (org number via normalize_org_number, VAT, bankgiro, plusgiro, printed name). Documents whose supplier org is the company's own are the company's sales invoices and only count in self_docs. - apply_party_suggestions(company, user, items): upserts suggestions. Attaches by explicit party_id, org number or an exact alias key; never by name. Identities become known at two sightings. Idempotent. - decide_parties(company, user, ids, kind, note): bulk confirm or dismiss with one party_decisions row each. - parties.suggested_reason: the evidence summary the queue shows per row. - lib/parties/suggest.ts: buildSuggestions (pure) and suggestPartiesForCompany. Keys that mix two org numbers keep neither the hard key nor identities; same-core live parties are reported as similar_to for a person to decide. coreKey() moves into ledger-key.ts. 55 unit tests and 14 pg-real tests pass locally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): decide_parties dismisses suggested parties only Dismiss is the queue's answer to a suggestion; a confirmed party is never archived through it. Superagent P2 on #2172. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(parties): type the rpc mock with its args so the ratchet stays clean Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
332 lines
16 KiB
PL/PgSQL
332 lines
16 KiB
PL/PgSQL
-- Parties, phase 1c: the suggestion pipeline.
|
|
--
|
|
-- A migrant's register is filled from what the ledger already knows: posted
|
|
-- vouchers keyed on ledger_key(description) (get_observed_parties), plus the
|
|
-- documents linked to those vouchers, whose OCR-read supplier block carries
|
|
-- the hard keys (org number, VAT number, bankgiro, plusgiro). Nothing here is
|
|
-- shown as a fact: every party this pipeline writes has status 'suggested'
|
|
-- and waits for a person to confirm or dismiss it. Text similarity never
|
|
-- merges anything; only an org number or an exact ledger key attaches a key
|
|
-- to an existing party (DECISIONS 2026-09-02, dedupe on org only).
|
|
--
|
|
-- Three functions, all SECURITY INVOKER so RLS on parties/party_* applies to
|
|
-- authenticated callers; the service role bypasses RLS as it does everywhere.
|
|
-- get_ledger_key_evidence(company) read: hard keys per ledger key
|
|
-- apply_party_suggestions(company, user, items) write: upsert suggestions
|
|
-- decide_parties(company, user, ids, kind, note) write: bulk confirm/dismiss
|
|
|
|
-- Why the queue can show a reason per row without a join.
|
|
ALTER TABLE public.parties ADD COLUMN IF NOT EXISTS suggested_reason jsonb;
|
|
COMMENT ON COLUMN public.parties.suggested_reason IS
|
|
'Why the pipeline suggested this party (evidence summary). NULL once confirmed by a person.';
|
|
|
|
-- ── Evidence per ledger key ─────────────────────────────────────────────────
|
|
-- Same voucher population as get_observed_parties, joined to current-version
|
|
-- documents whose extracted supplier block is an object. Documents whose
|
|
-- supplier org number is the company's own are the company's sales invoices
|
|
-- uploaded as underlag (40% of org-bearing documents fleet-wide); they are
|
|
-- counted in self_docs and contribute nothing else.
|
|
CREATE OR REPLACE FUNCTION public.get_ledger_key_evidence(p_company_id uuid)
|
|
RETURNS jsonb
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
WITH own AS (
|
|
SELECT public.normalize_org_number(c.org_number) AS org
|
|
FROM public.companies c WHERE c.id = p_company_id
|
|
),
|
|
entries AS (
|
|
SELECT je.id, je.entry_date, public.ledger_key(je.description) AS k
|
|
FROM public.journal_entries je
|
|
WHERE je.company_id = p_company_id
|
|
AND je.status = 'posted'
|
|
AND je.source_type NOT IN ('storno', 'opening_balance', 'year_end', 'vat_settlement')
|
|
AND je.description IS NOT NULL
|
|
AND btrim(je.description) <> ''
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.transactions t
|
|
WHERE t.journal_entry_id = je.id
|
|
AND t.merchant_name IS NOT NULL
|
|
AND btrim(t.merchant_name) <> ''
|
|
)
|
|
),
|
|
docs AS (
|
|
SELECT e.k, e.entry_date, d.id AS document_id,
|
|
public.normalize_org_number(d.extracted_data->'supplier'->>'orgNumber') AS org,
|
|
nullif(upper(regexp_replace(coalesce(d.extracted_data->'supplier'->>'vatNumber', ''), '[^0-9A-Za-z]', '', 'g')), '') AS vat,
|
|
nullif(regexp_replace(coalesce(d.extracted_data->'supplier'->>'bankgiro', ''), '[^0-9]', '', 'g'), '') AS bankgiro,
|
|
nullif(regexp_replace(coalesce(d.extracted_data->'supplier'->>'plusgiro', ''), '[^0-9]', '', 'g'), '') AS plusgiro,
|
|
nullif(btrim(d.extracted_data->'supplier'->>'name'), '') AS name
|
|
FROM entries e
|
|
JOIN public.document_attachments d
|
|
ON d.journal_entry_id = e.id
|
|
AND d.company_id = p_company_id
|
|
AND d.is_current_version
|
|
AND jsonb_typeof(d.extracted_data->'supplier') = 'object'
|
|
WHERE e.k <> ''
|
|
),
|
|
classified AS (
|
|
SELECT d.*, (d.org IS NOT NULL AND d.org = own.org) AS is_self
|
|
FROM docs d CROSS JOIN own
|
|
),
|
|
useful AS (SELECT * FROM classified WHERE NOT is_self),
|
|
orgs AS (
|
|
SELECT k, org, count(*) AS n FROM useful WHERE org IS NOT NULL GROUP BY k, org
|
|
),
|
|
vats AS (
|
|
SELECT k, vat, count(*) AS n FROM useful WHERE vat IS NOT NULL GROUP BY k, vat
|
|
),
|
|
names AS (
|
|
SELECT k, name, count(*) AS n FROM useful WHERE name IS NOT NULL GROUP BY k, name
|
|
),
|
|
bg AS (
|
|
SELECT k, bankgiro AS value, count(*) AS n, min(entry_date) AS first_seen, max(entry_date) AS last_seen
|
|
FROM useful WHERE bankgiro IS NOT NULL AND length(bankgiro) BETWEEN 7 AND 8 GROUP BY k, bankgiro
|
|
),
|
|
pg AS (
|
|
SELECT k, plusgiro AS value, count(*) AS n, min(entry_date) AS first_seen, max(entry_date) AS last_seen
|
|
FROM useful WHERE plusgiro IS NOT NULL AND length(plusgiro) BETWEEN 5 AND 8 GROUP BY k, plusgiro
|
|
),
|
|
per_key AS (
|
|
SELECT c.k,
|
|
count(*) AS docs,
|
|
count(*) FILTER (WHERE c.is_self) AS self_docs
|
|
FROM classified c GROUP BY c.k
|
|
)
|
|
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
'key', p.k,
|
|
'docs', p.docs,
|
|
'self_docs', p.self_docs,
|
|
'orgs', coalesce((SELECT jsonb_agg(jsonb_build_object('org', o.org, 'n', o.n) ORDER BY o.n DESC, o.org) FROM orgs o WHERE o.k = p.k), '[]'::jsonb),
|
|
'vat_numbers', coalesce((SELECT jsonb_agg(jsonb_build_object('vat', v.vat, 'n', v.n) ORDER BY v.n DESC, v.vat) FROM vats v WHERE v.k = p.k), '[]'::jsonb),
|
|
'names', coalesce((SELECT jsonb_agg(jsonb_build_object('name', x.name, 'n', x.n) ORDER BY x.n DESC, x.name) FROM names x WHERE x.k = p.k), '[]'::jsonb),
|
|
'bankgiro', coalesce((SELECT jsonb_agg(jsonb_build_object('value', b.value, 'n', b.n, 'first_seen', b.first_seen, 'last_seen', b.last_seen) ORDER BY b.n DESC, b.value) FROM bg b WHERE b.k = p.k), '[]'::jsonb),
|
|
'plusgiro', coalesce((SELECT jsonb_agg(jsonb_build_object('value', g.value, 'n', g.n, 'first_seen', g.first_seen, 'last_seen', g.last_seen) ORDER BY g.n DESC, g.value) FROM pg g WHERE g.k = p.k), '[]'::jsonb)
|
|
) ORDER BY p.docs DESC, p.k), '[]'::jsonb)
|
|
FROM per_key p;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.get_ledger_key_evidence(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.get_ledger_key_evidence(uuid) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.get_ledger_key_evidence(uuid) IS
|
|
'Hard keys (org, VAT, bankgiro, plusgiro, printed name) per ledger_key from documents linked to posted vouchers. Self-extracted documents (supplier org = own org) count only in self_docs.';
|
|
|
|
-- ── Apply suggestions ───────────────────────────────────────────────────────
|
|
-- p_items: array of
|
|
-- { key, display_name, kind?, origin?, org_number?, vat_number?, alias_keys?,
|
|
-- party_id?, reason?, facts?: [{field, value, source, reference?}],
|
|
-- identities?: [{scheme, value, first_seen?, last_seen?, seen_count?}] }
|
|
-- Attach order: explicit party_id, then live party with the same org number,
|
|
-- then live party whose alias_keys already contains the key; otherwise insert
|
|
-- a suggested party. Never by name. Re-running is idempotent.
|
|
CREATE OR REPLACE FUNCTION public.apply_party_suggestions(
|
|
p_company_id uuid,
|
|
p_user_id uuid,
|
|
p_items jsonb
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_item jsonb;
|
|
v_fact jsonb;
|
|
v_ident jsonb;
|
|
v_party_id uuid;
|
|
v_key text;
|
|
v_org text;
|
|
v_aliases text[];
|
|
v_created integer := 0;
|
|
v_attached integer := 0;
|
|
v_identities integer := 0;
|
|
v_facts integer := 0;
|
|
v_seen integer;
|
|
BEGIN
|
|
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'apply_party_suggestions: p_user_id must be the caller' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_items IS NULL OR jsonb_typeof(p_items) <> 'array' THEN
|
|
RAISE EXCEPTION 'apply_party_suggestions: p_items must be a JSON array' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
|
|
v_key := nullif(btrim(coalesce(v_item->>'key', '')), '');
|
|
IF v_key IS NULL THEN
|
|
RAISE EXCEPTION 'apply_party_suggestions: every item needs a key' USING ERRCODE = '22023';
|
|
END IF;
|
|
v_org := public.normalize_org_number(v_item->>'org_number');
|
|
v_aliases := ARRAY(SELECT DISTINCT x FROM (
|
|
SELECT v_key AS x UNION ALL SELECT jsonb_array_elements_text(coalesce(v_item->'alias_keys', '[]'::jsonb))
|
|
) a WHERE x IS NOT NULL AND btrim(x) <> '');
|
|
|
|
v_party_id := NULL;
|
|
IF v_item->>'party_id' IS NOT NULL THEN
|
|
SELECT id INTO v_party_id FROM public.parties
|
|
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL;
|
|
IF v_party_id IS NULL THEN
|
|
RAISE EXCEPTION 'apply_party_suggestions: party % is not a live party of this company', v_item->>'party_id'
|
|
USING ERRCODE = '23503';
|
|
END IF;
|
|
END IF;
|
|
IF v_party_id IS NULL AND v_org IS NOT NULL THEN
|
|
SELECT id INTO v_party_id FROM public.parties
|
|
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
|
|
END IF;
|
|
IF v_party_id IS NULL THEN
|
|
SELECT id INTO v_party_id FROM public.parties
|
|
WHERE company_id = p_company_id AND merged_into IS NULL AND alias_keys @> ARRAY[v_key]
|
|
ORDER BY (status = 'confirmed') DESC, created_at
|
|
LIMIT 1;
|
|
END IF;
|
|
|
|
IF v_party_id IS NULL THEN
|
|
INSERT INTO public.parties (company_id, user_id, display_name, legal_name, kind, status, org_number, vat_number, alias_keys, origin, suggested_reason)
|
|
VALUES (
|
|
p_company_id, p_user_id,
|
|
coalesce(nullif(btrim(v_item->>'display_name'), ''), v_key),
|
|
nullif(btrim(v_item->>'legal_name'), ''),
|
|
coalesce(v_item->>'kind', 'company'),
|
|
'suggested',
|
|
v_org,
|
|
nullif(btrim(v_item->>'vat_number'), ''),
|
|
v_aliases,
|
|
coalesce(v_item->>'origin', 'ledger'),
|
|
v_item->'reason'
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id INTO v_party_id;
|
|
IF v_party_id IS NULL THEN
|
|
-- Lost a race on (company_id, org_number): attach to the winner.
|
|
SELECT id INTO v_party_id FROM public.parties
|
|
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
|
|
v_attached := v_attached + 1;
|
|
ELSE
|
|
v_created := v_created + 1;
|
|
END IF;
|
|
ELSE
|
|
v_attached := v_attached + 1;
|
|
UPDATE public.parties
|
|
SET alias_keys = ARRAY(SELECT DISTINCT x FROM unnest(alias_keys || v_aliases) AS x),
|
|
vat_number = coalesce(vat_number, nullif(btrim(v_item->>'vat_number'), '')),
|
|
legal_name = coalesce(legal_name, nullif(btrim(v_item->>'legal_name'), '')),
|
|
org_number = coalesce(org_number, v_org)
|
|
WHERE id = v_party_id
|
|
AND (NOT (alias_keys @> v_aliases)
|
|
OR (vat_number IS NULL AND nullif(btrim(v_item->>'vat_number'), '') IS NOT NULL)
|
|
OR (legal_name IS NULL AND nullif(btrim(v_item->>'legal_name'), '') IS NOT NULL)
|
|
OR (org_number IS NULL AND v_org IS NOT NULL));
|
|
END IF;
|
|
|
|
FOR v_ident IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'identities', '[]'::jsonb)) LOOP
|
|
v_seen := greatest(coalesce((v_ident->>'seen_count')::integer, 1), 1);
|
|
INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, status, source, first_seen, last_seen, seen_count)
|
|
VALUES (
|
|
v_party_id, p_company_id, p_user_id,
|
|
v_ident->>'scheme', v_ident->>'value',
|
|
CASE WHEN v_seen >= 2 THEN 'known' ELSE 'unverified' END,
|
|
coalesce(v_ident->>'source', 'document'),
|
|
(v_ident->>'first_seen')::date, (v_ident->>'last_seen')::date, v_seen
|
|
)
|
|
ON CONFLICT (party_id, scheme, value) DO UPDATE
|
|
SET seen_count = greatest(party_identities.seen_count, EXCLUDED.seen_count),
|
|
first_seen = least(party_identities.first_seen, EXCLUDED.first_seen),
|
|
last_seen = greatest(party_identities.last_seen, EXCLUDED.last_seen),
|
|
status = CASE WHEN greatest(party_identities.seen_count, EXCLUDED.seen_count) >= 2 THEN 'known' ELSE party_identities.status END
|
|
WHERE party_identities.seen_count < EXCLUDED.seen_count
|
|
OR party_identities.last_seen IS DISTINCT FROM greatest(party_identities.last_seen, EXCLUDED.last_seen)
|
|
OR party_identities.first_seen IS DISTINCT FROM least(party_identities.first_seen, EXCLUDED.first_seen);
|
|
v_identities := v_identities + 1;
|
|
END LOOP;
|
|
|
|
FOR v_fact IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'facts', '[]'::jsonb)) LOOP
|
|
INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source, reference)
|
|
SELECT v_party_id, p_company_id, p_user_id, v_fact->>'field', v_fact->'value', v_fact->>'source', v_fact->'reference'
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM public.party_facts f
|
|
WHERE f.party_id = v_party_id AND f.field = v_fact->>'field' AND f.source = v_fact->>'source'
|
|
AND f.value = v_fact->'value' AND f.superseded_at IS NULL
|
|
);
|
|
IF FOUND THEN v_facts := v_facts + 1; END IF;
|
|
END LOOP;
|
|
END LOOP;
|
|
|
|
RETURN jsonb_build_object('created', v_created, 'attached', v_attached, 'identities', v_identities, 'facts', v_facts);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.apply_party_suggestions(uuid, uuid, jsonb) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.apply_party_suggestions(uuid, uuid, jsonb) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.apply_party_suggestions(uuid, uuid, jsonb) IS
|
|
'Upserts pipeline suggestions: attaches by explicit party_id, org number or exact ledger key, otherwise inserts a suggested party. Never merges on name. Idempotent.';
|
|
|
|
-- ── Decide: bulk confirm or dismiss ─────────────────────────────────────────
|
|
CREATE OR REPLACE FUNCTION public.decide_parties(
|
|
p_company_id uuid,
|
|
p_user_id uuid,
|
|
p_party_ids uuid[],
|
|
p_kind text,
|
|
p_note text DEFAULT NULL
|
|
)
|
|
RETURNS integer
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_count integer := 0;
|
|
BEGIN
|
|
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'decide_parties: p_user_id must be the caller' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_kind NOT IN ('confirm', 'dismiss') THEN
|
|
RAISE EXCEPTION 'decide_parties: kind must be confirm or dismiss, got %', p_kind USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF p_kind = 'confirm' THEN
|
|
WITH changed AS (
|
|
UPDATE public.parties p
|
|
SET status = 'confirmed', suggested_reason = NULL, archived_at = NULL
|
|
WHERE p.company_id = p_company_id AND p.id = ANY(p_party_ids) AND p.merged_into IS NULL
|
|
AND (p.status <> 'confirmed' OR p.archived_at IS NOT NULL)
|
|
RETURNING p.id, p.display_name
|
|
), logged AS (
|
|
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
|
|
SELECT c.id, p_company_id, p_user_id, 'confirm',
|
|
jsonb_build_object('status', 'suggested'), jsonb_build_object('status', 'confirmed'), p_note
|
|
FROM changed c
|
|
RETURNING 1
|
|
)
|
|
SELECT count(*) INTO v_count FROM logged;
|
|
ELSE
|
|
-- Dismiss is the queue's "not a party" answer: it only touches suggested
|
|
-- rows. A confirmed party is archived through its own action later.
|
|
WITH changed AS (
|
|
UPDATE public.parties p
|
|
SET archived_at = now()
|
|
WHERE p.company_id = p_company_id AND p.id = ANY(p_party_ids) AND p.merged_into IS NULL
|
|
AND p.status = 'suggested' AND p.archived_at IS NULL
|
|
RETURNING p.id, p.status
|
|
), logged AS (
|
|
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
|
|
SELECT c.id, p_company_id, p_user_id, 'dismiss',
|
|
jsonb_build_object('status', c.status, 'archived', false), jsonb_build_object('status', c.status, 'archived', true), p_note
|
|
FROM changed c
|
|
RETURNING 1
|
|
)
|
|
SELECT count(*) INTO v_count FROM logged;
|
|
END IF;
|
|
|
|
RETURN v_count;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.decide_parties(uuid, uuid, uuid[], text, text) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.decide_parties(uuid, uuid, uuid[], text, text) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.decide_parties(uuid, uuid, uuid[], text, text) IS
|
|
'Bulk confirm (suggested -> confirmed) or dismiss (archive a suggested party), one party_decisions row each. Confirmed parties are never dismissed here. Merge and undo live in their own RPCs.';
|
|
|
|
NOTIFY pgrst, 'reload schema';
|