@fayz-ai/db 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/canonical-schema.json +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -1,482 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 018_global_search.sql — ONE indexed lookup for "find me anything".
|
|
3
|
-
-- ----------------------------------------------------------------------------
|
|
4
|
-
-- The command palette and the agent's findAnything both answered the same
|
|
5
|
-
-- question — "which record is this text?" — by asking every entity separately:
|
|
6
|
-
-- N round-trips of `column ILIKE '%term%'`, accent-sensitive, single-word only,
|
|
7
|
-
-- and unindexable (every one of them a sequential scan). It is correct at
|
|
8
|
-
-- fixture size and unusable at ten thousand clients.
|
|
9
|
-
--
|
|
10
|
-
-- This replaces the fan-out with a spine primitive:
|
|
11
|
-
--
|
|
12
|
-
-- public.fayz_norm(text) fold: strip accents, lowercase, collapse
|
|
13
|
-
-- punctuation. IMMUTABLE, so it can be
|
|
14
|
-
-- INDEXED. Mirrors foldText() in
|
|
15
|
-
-- @fayz-ai/core/src/search/text.ts — the two
|
|
16
|
-
-- must agree or server and client disagree
|
|
17
|
-
-- about what matched.
|
|
18
|
-
-- public.fayz_digits(text) digits only — phones, CPF/CNPJ, SKUs.
|
|
19
|
-
-- public.fayz_search_sources WHAT is searchable. A registry row per
|
|
20
|
-
-- source, the SQL mirror of the client's
|
|
21
|
-
-- entity registry. Plugins and apps add their
|
|
22
|
-
-- own rows from their own migrations; nothing
|
|
23
|
-
-- here is hard-coded into the function.
|
|
24
|
-
-- public.fayz_search_reindex() builds the GIN trigram index for every
|
|
25
|
-
-- registered source FROM THE REGISTRY, so the
|
|
26
|
-
-- index expression is the query expression by
|
|
27
|
-
-- construction (an index built by hand from a
|
|
28
|
-
-- retyped expression is an index the planner
|
|
29
|
-
-- silently refuses to use).
|
|
30
|
-
-- public.fayz_global_search(...) one call, all sources, ranked, capped.
|
|
31
|
-
--
|
|
32
|
-
-- Why trigram GIN and not tsvector full-text: people search a CRM for fragments
|
|
33
|
-
-- and typos ("bigodin", "jose", "1198"), not for stemmed words. `%term%` under
|
|
34
|
-
-- a gin_trgm_ops index is an index scan; under FTS it is not expressible at all.
|
|
35
|
-
--
|
|
36
|
-
-- SECURITY INVOKER on purpose: the search runs as the caller, so RLS decides
|
|
37
|
-
-- which rows exist. `p_tenant_id` narrows, it does not authorize.
|
|
38
|
-
--
|
|
39
|
-
-- Replay safety: every statement is CREATE OR REPLACE / IF NOT EXISTS /
|
|
40
|
-
-- ON CONFLICT DO UPDATE. Re-running is a no-op. Degrades cleanly on a pool
|
|
41
|
-
-- without pg_trgm — the function still answers, just without index support and
|
|
42
|
-
-- without typo tolerance.
|
|
43
|
-
-- ============================================================================
|
|
44
|
-
|
|
45
|
-
-- ----------------------------------------------------------------------------
|
|
46
|
-
-- 0. Extensions (best effort — a managed pool usually has them already)
|
|
47
|
-
-- ----------------------------------------------------------------------------
|
|
48
|
-
DO $$
|
|
49
|
-
DECLARE
|
|
50
|
-
v_schema text := CASE WHEN EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'extensions')
|
|
51
|
-
THEN 'extensions' ELSE 'public' END;
|
|
52
|
-
BEGIN
|
|
53
|
-
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN
|
|
54
|
-
BEGIN
|
|
55
|
-
EXECUTE format('CREATE EXTENSION pg_trgm WITH SCHEMA %I', v_schema);
|
|
56
|
-
EXCEPTION WHEN OTHERS THEN
|
|
57
|
-
RAISE NOTICE 'fayz search: pg_trgm unavailable (%), falling back to unindexed LIKE', SQLERRM;
|
|
58
|
-
END;
|
|
59
|
-
END IF;
|
|
60
|
-
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gin') THEN
|
|
61
|
-
BEGIN
|
|
62
|
-
EXECUTE format('CREATE EXTENSION btree_gin WITH SCHEMA %I', v_schema);
|
|
63
|
-
EXCEPTION WHEN OTHERS THEN
|
|
64
|
-
RAISE NOTICE 'fayz search: btree_gin unavailable (%), tenant filter stays a recheck', SQLERRM;
|
|
65
|
-
END;
|
|
66
|
-
END IF;
|
|
67
|
-
END $$;
|
|
68
|
-
|
|
69
|
-
-- ----------------------------------------------------------------------------
|
|
70
|
-
-- 1. Folding — the one definition of "same text"
|
|
71
|
-
-- ----------------------------------------------------------------------------
|
|
72
|
-
-- translate() rather than unaccent(): unaccent is STABLE (it depends on a
|
|
73
|
-
-- dictionary), so it cannot appear in an index expression without an IMMUTABLE
|
|
74
|
-
-- wrapper whose correctness depends on nobody ever editing the dictionary.
|
|
75
|
-
-- A fixed character map is IMMUTABLE by construction and covers every accent
|
|
76
|
-
-- the fleet's Latin-script locales actually produce.
|
|
77
|
-
CREATE OR REPLACE FUNCTION public.fayz_norm(p_text text)
|
|
78
|
-
RETURNS text
|
|
79
|
-
LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
|
80
|
-
AS $$
|
|
81
|
-
SELECT btrim(regexp_replace(
|
|
82
|
-
lower(translate(
|
|
83
|
-
coalesce(p_text, ''),
|
|
84
|
-
'ÀÁÂÃÄÅàáâãäåÈÉÊËèéêëÌÍÎÏìíîïÒÓÔÕÖòóôõöÙÚÛÜùúûüÇçÑñÝýÿŠšŽžØøÆæŒœÐðÞþıŁł',
|
|
85
|
-
'AAAAAAaaaaaaEEEEeeeeIIIIiiiiOOOOOoooooUUUUuuuuCcNnYyySsZzOoAaOoDdTtiLl'
|
|
86
|
-
)),
|
|
87
|
-
'[^a-z0-9]+', ' ', 'g'))
|
|
88
|
-
$$;
|
|
89
|
-
|
|
90
|
-
COMMENT ON FUNCTION public.fayz_norm(text) IS
|
|
91
|
-
'Search folding: strip diacritics, lowercase, collapse non-alphanumerics to single spaces. IMMUTABLE so it can be indexed. Mirrored by foldText() in @fayz-ai/core.';
|
|
92
|
-
|
|
93
|
-
CREATE OR REPLACE FUNCTION public.fayz_digits(p_text text)
|
|
94
|
-
RETURNS text
|
|
95
|
-
LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
|
96
|
-
AS $$ SELECT regexp_replace(coalesce(p_text, ''), '[^0-9]+', '', 'g') $$;
|
|
97
|
-
|
|
98
|
-
COMMENT ON FUNCTION public.fayz_digits(text) IS
|
|
99
|
-
'Digits only — lets "(11) 98765-4321", "11987654321" and "98765" all find the same phone.';
|
|
100
|
-
|
|
101
|
-
-- concat_ws(), concat() and array_to_string() are all STABLE (they route through
|
|
102
|
-
-- type output functions), which bars them from an index expression — Postgres
|
|
103
|
-
-- rejects the CREATE INDEX outright. A haystack has to be assembled from
|
|
104
|
-
-- something IMMUTABLE, and for text arguments with a fixed separator the
|
|
105
|
-
-- assembly genuinely is: same input, same output, forever.
|
|
106
|
-
CREATE OR REPLACE FUNCTION public.fayz_cat(VARIADIC p_parts text[])
|
|
107
|
-
RETURNS text
|
|
108
|
-
LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
|
109
|
-
AS $$ SELECT coalesce(array_to_string(p_parts, ' '), '') $$;
|
|
110
|
-
|
|
111
|
-
COMMENT ON FUNCTION public.fayz_cat(text[]) IS
|
|
112
|
-
'IMMUTABLE space-join of text values, NULLs skipped. The only concatenation allowed inside a fayz_search_sources haystack_expr — concat_ws is STABLE and cannot be indexed.';
|
|
113
|
-
|
|
114
|
-
-- ----------------------------------------------------------------------------
|
|
115
|
-
-- 2. The source registry — what global search is allowed to look at
|
|
116
|
-
-- ----------------------------------------------------------------------------
|
|
117
|
-
CREATE TABLE IF NOT EXISTS public.fayz_search_sources (
|
|
118
|
-
-- Matches the client's deriveEntityKey(). Sources with a `kind_column` emit
|
|
119
|
-
-- `<entity_key>:<kind>` per row, which is exactly how archetype entities are
|
|
120
|
-
-- keyed on the client ('person' + kind 'customer' → 'person:customer').
|
|
121
|
-
entity_key text PRIMARY KEY,
|
|
122
|
-
relation text NOT NULL, -- 'public.people'
|
|
123
|
-
id_column text NOT NULL DEFAULT 'id',
|
|
124
|
-
tenant_column text NOT NULL DEFAULT 'tenant_id',
|
|
125
|
-
kind_column text,
|
|
126
|
-
title_expr text NOT NULL, -- what the row is CALLED
|
|
127
|
-
subtitle_expr text, -- the disambiguating line
|
|
128
|
-
haystack_expr text NOT NULL, -- everything matchable
|
|
129
|
-
digits_expr text, -- phone/document columns
|
|
130
|
-
filter_expr text, -- extra SQL predicate
|
|
131
|
-
icon text,
|
|
132
|
-
weight numeric NOT NULL DEFAULT 1, -- multiplies the rank
|
|
133
|
-
enabled boolean NOT NULL DEFAULT true,
|
|
134
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
COMMENT ON TABLE public.fayz_search_sources IS
|
|
138
|
-
'Registry of searchable relations for public.fayz_global_search. Readable by any member, writable only by the migration role — the expressions are executed as SQL.';
|
|
139
|
-
|
|
140
|
-
ALTER TABLE public.fayz_search_sources ENABLE ROW LEVEL SECURITY;
|
|
141
|
-
|
|
142
|
-
DO $$ BEGIN
|
|
143
|
-
IF NOT EXISTS (
|
|
144
|
-
SELECT 1 FROM pg_policies
|
|
145
|
-
WHERE schemaname = 'public' AND tablename = 'fayz_search_sources'
|
|
146
|
-
AND policyname = 'fayz_search_sources_read'
|
|
147
|
-
) THEN
|
|
148
|
-
-- Not tenant-scoped: the catalog describes SHAPES, never rows. Row access
|
|
149
|
-
-- is decided by each source relation's own RLS when the search runs.
|
|
150
|
-
CREATE POLICY "fayz_search_sources_read" ON public.fayz_search_sources
|
|
151
|
-
FOR SELECT TO authenticated USING (true);
|
|
152
|
-
END IF;
|
|
153
|
-
END $$;
|
|
154
|
-
|
|
155
|
-
REVOKE ALL ON public.fayz_search_sources FROM anon;
|
|
156
|
-
GRANT SELECT ON public.fayz_search_sources TO authenticated;
|
|
157
|
-
|
|
158
|
-
-- ----------------------------------------------------------------------------
|
|
159
|
-
-- 3. Spine sources
|
|
160
|
-
-- ----------------------------------------------------------------------------
|
|
161
|
-
-- `weight` is the editorial call about what a business means when it types a
|
|
162
|
-
-- name: a person first, then what it sells, then where and when. Ledger rows
|
|
163
|
-
-- are found by number, so they sit lower.
|
|
164
|
-
INSERT INTO public.fayz_search_sources
|
|
165
|
-
(entity_key, relation, kind_column, title_expr, subtitle_expr, haystack_expr, digits_expr, filter_expr, icon, weight)
|
|
166
|
-
VALUES
|
|
167
|
-
('person', 'public.people', 'kind',
|
|
168
|
-
'name',
|
|
169
|
-
$sub$nullif(concat_ws(' · ', nullif(email,''), nullif(phone,'')), '')$sub$,
|
|
170
|
-
$hay$public.fayz_cat(name, email, phone, document_number, notes, public.fayz_cat(VARIADIC tags))$hay$,
|
|
171
|
-
$dig$concat_ws(' ', phone, document_number)$dig$,
|
|
172
|
-
NULL, 'User', 1.00),
|
|
173
|
-
|
|
174
|
-
('product', 'public.products', NULL,
|
|
175
|
-
'name',
|
|
176
|
-
$sub$nullif(concat_ws(' · ', nullif(sku,''), nullif(description,'')), '')$sub$,
|
|
177
|
-
$hay$public.fayz_cat(name, sku, description, public.fayz_cat(VARIADIC tags))$hay$,
|
|
178
|
-
$dig$sku$dig$,
|
|
179
|
-
NULL, 'Box', 0.95),
|
|
180
|
-
|
|
181
|
-
('service', 'public.services', NULL,
|
|
182
|
-
'name',
|
|
183
|
-
$sub$nullif(description, '')$sub$,
|
|
184
|
-
$hay$public.fayz_cat(name, description, public.fayz_cat(VARIADIC tags))$hay$,
|
|
185
|
-
NULL,
|
|
186
|
-
NULL, 'Sparkles', 0.95),
|
|
187
|
-
|
|
188
|
-
('location', 'public.locations', 'kind',
|
|
189
|
-
'name',
|
|
190
|
-
$sub$nullif(concat_ws(' · ', nullif(city,''), nullif(state,'')), '')$sub$,
|
|
191
|
-
$hay$public.fayz_cat(name, email, phone, address, city, state, postal_code)$hay$,
|
|
192
|
-
$dig$concat_ws(' ', phone, postal_code)$dig$,
|
|
193
|
-
NULL, 'MapPin', 0.80),
|
|
194
|
-
|
|
195
|
-
('category', 'public.categories', 'kind',
|
|
196
|
-
'name',
|
|
197
|
-
$sub$nullif(slug, '')$sub$,
|
|
198
|
-
$hay$public.fayz_cat(name, slug)$hay$,
|
|
199
|
-
NULL,
|
|
200
|
-
NULL, 'Tag', 0.60),
|
|
201
|
-
|
|
202
|
-
('address', 'public.addresses', NULL,
|
|
203
|
-
$tit$concat_ws(', ', nullif(street,''), nullif(number,''))$tit$,
|
|
204
|
-
$sub$nullif(concat_ws(' · ', nullif(district,''), nullif(city,''), nullif(postal_code,'')), '')$sub$,
|
|
205
|
-
$hay$public.fayz_cat(label, recipient, street, number, complement, district, city, state, postal_code)$hay$,
|
|
206
|
-
$dig$concat_ws(' ', postal_code, phone)$dig$,
|
|
207
|
-
NULL, 'MapPin', 0.55)
|
|
208
|
-
ON CONFLICT (entity_key) DO UPDATE SET
|
|
209
|
-
relation = EXCLUDED.relation,
|
|
210
|
-
kind_column = EXCLUDED.kind_column,
|
|
211
|
-
title_expr = EXCLUDED.title_expr,
|
|
212
|
-
subtitle_expr = EXCLUDED.subtitle_expr,
|
|
213
|
-
haystack_expr = EXCLUDED.haystack_expr,
|
|
214
|
-
digits_expr = EXCLUDED.digits_expr,
|
|
215
|
-
filter_expr = EXCLUDED.filter_expr,
|
|
216
|
-
icon = EXCLUDED.icon,
|
|
217
|
-
weight = EXCLUDED.weight;
|
|
218
|
-
|
|
219
|
-
-- Order/transaction sources only make sense once those tables carry a human
|
|
220
|
-
-- reference; they are registered by the plugins that own that shape.
|
|
221
|
-
|
|
222
|
-
-- ----------------------------------------------------------------------------
|
|
223
|
-
-- 4. Index builder — derived from the registry, never retyped
|
|
224
|
-
-- ----------------------------------------------------------------------------
|
|
225
|
-
CREATE OR REPLACE FUNCTION public.fayz_search_reindex()
|
|
226
|
-
RETURNS integer
|
|
227
|
-
LANGUAGE plpgsql VOLATILE
|
|
228
|
-
SET search_path = public, extensions, pg_temp
|
|
229
|
-
AS $$
|
|
230
|
-
DECLARE
|
|
231
|
-
v_src record;
|
|
232
|
-
v_rel text;
|
|
233
|
-
v_name text;
|
|
234
|
-
v_built integer := 0;
|
|
235
|
-
v_has_trgm boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm');
|
|
236
|
-
v_has_gin boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gin');
|
|
237
|
-
BEGIN
|
|
238
|
-
IF NOT v_has_trgm THEN
|
|
239
|
-
RAISE NOTICE 'fayz search: pg_trgm missing — no index built, search falls back to sequential LIKE';
|
|
240
|
-
RETURN 0;
|
|
241
|
-
END IF;
|
|
242
|
-
|
|
243
|
-
FOR v_src IN
|
|
244
|
-
SELECT * FROM public.fayz_search_sources WHERE enabled ORDER BY entity_key
|
|
245
|
-
LOOP
|
|
246
|
-
v_rel := to_regclass(v_src.relation)::text;
|
|
247
|
-
CONTINUE WHEN v_rel IS NULL;
|
|
248
|
-
-- A view cannot be indexed; only its base tables can.
|
|
249
|
-
CONTINUE WHEN (SELECT c.relkind FROM pg_class c WHERE c.oid = to_regclass(v_src.relation))
|
|
250
|
-
NOT IN ('r', 'p');
|
|
251
|
-
|
|
252
|
-
-- Name derived from (relation, expression): two sources over the same
|
|
253
|
-
-- columns share one index instead of building it twice.
|
|
254
|
-
v_name := 'fayz_search_' || substr(md5(v_rel || '|' || v_src.haystack_expr), 1, 20);
|
|
255
|
-
|
|
256
|
-
BEGIN
|
|
257
|
-
IF v_has_gin THEN
|
|
258
|
-
-- Composite: the tenant equality is resolved INSIDE the index, so a
|
|
259
|
-
-- 500-tenant pool does not trigram-scan its neighbours' rows.
|
|
260
|
-
EXECUTE format(
|
|
261
|
-
'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (%I, public.fayz_norm(%s) gin_trgm_ops)',
|
|
262
|
-
v_name, v_rel, v_src.tenant_column, v_src.haystack_expr);
|
|
263
|
-
ELSE
|
|
264
|
-
EXECUTE format(
|
|
265
|
-
'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
|
|
266
|
-
v_name, v_rel, v_src.haystack_expr);
|
|
267
|
-
END IF;
|
|
268
|
-
v_built := v_built + 1;
|
|
269
|
-
EXCEPTION WHEN OTHERS THEN
|
|
270
|
-
-- Most likely cause: btree_gin present but with no GIN opclass for the
|
|
271
|
-
-- tenant column's type. A trigram-only index still serves every query;
|
|
272
|
-
-- the tenant equality just becomes a recheck.
|
|
273
|
-
BEGIN
|
|
274
|
-
EXECUTE format(
|
|
275
|
-
'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
|
|
276
|
-
v_name, v_rel, v_src.haystack_expr);
|
|
277
|
-
v_built := v_built + 1;
|
|
278
|
-
EXCEPTION WHEN OTHERS THEN
|
|
279
|
-
RAISE NOTICE 'fayz search: could not index % (%): %', v_src.entity_key, v_rel, SQLERRM;
|
|
280
|
-
END;
|
|
281
|
-
END;
|
|
282
|
-
END LOOP;
|
|
283
|
-
|
|
284
|
-
RETURN v_built;
|
|
285
|
-
END $$;
|
|
286
|
-
|
|
287
|
-
COMMENT ON FUNCTION public.fayz_search_reindex() IS
|
|
288
|
-
'Builds/refreshes the GIN trigram index for every enabled row of fayz_search_sources. Call it after inserting your own source row.';
|
|
289
|
-
|
|
290
|
-
SELECT public.fayz_search_reindex();
|
|
291
|
-
|
|
292
|
-
-- ----------------------------------------------------------------------------
|
|
293
|
-
-- 5. The search itself
|
|
294
|
-
-- ----------------------------------------------------------------------------
|
|
295
|
-
DO $$ BEGIN
|
|
296
|
-
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'fayz_search_hit'
|
|
297
|
-
AND typnamespace = 'public'::regnamespace) THEN
|
|
298
|
-
CREATE TYPE public.fayz_search_hit AS (
|
|
299
|
-
entity_key text,
|
|
300
|
-
record_id text,
|
|
301
|
-
title text,
|
|
302
|
-
subtitle text,
|
|
303
|
-
score real
|
|
304
|
-
);
|
|
305
|
-
END IF;
|
|
306
|
-
END $$;
|
|
307
|
-
|
|
308
|
-
CREATE OR REPLACE FUNCTION public.fayz_global_search(
|
|
309
|
-
p_query text,
|
|
310
|
-
p_tenant_id uuid,
|
|
311
|
-
p_entity_keys text[] DEFAULT NULL,
|
|
312
|
-
p_limit integer DEFAULT 30,
|
|
313
|
-
p_per_source integer DEFAULT 8
|
|
314
|
-
)
|
|
315
|
-
RETURNS TABLE (entity_key text, record_id text, title text, subtitle text, score real)
|
|
316
|
-
LANGUAGE plpgsql STABLE SECURITY INVOKER
|
|
317
|
-
SET search_path = public, extensions, pg_temp
|
|
318
|
-
AS $$
|
|
319
|
-
DECLARE
|
|
320
|
-
v_q text := public.fayz_norm(p_query);
|
|
321
|
-
v_digits text := public.fayz_digits(p_query);
|
|
322
|
-
v_tokens text[];
|
|
323
|
-
v_token text;
|
|
324
|
-
v_src record;
|
|
325
|
-
v_acc public.fayz_search_hit[] := '{}';
|
|
326
|
-
v_rows public.fayz_search_hit[];
|
|
327
|
-
v_title text;
|
|
328
|
-
v_hay text;
|
|
329
|
-
v_match text;
|
|
330
|
-
v_where text;
|
|
331
|
-
v_key text;
|
|
332
|
-
v_kinds text[];
|
|
333
|
-
v_sql text;
|
|
334
|
-
v_phase integer;
|
|
335
|
-
v_has_trgm boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm');
|
|
336
|
-
BEGIN
|
|
337
|
-
-- One character matches every row; two is where a lookup starts to mean
|
|
338
|
-
-- something. The client enforces the same floor before it even calls.
|
|
339
|
-
IF v_q IS NULL OR length(v_q) < 2 THEN RETURN; END IF;
|
|
340
|
-
IF p_tenant_id IS NULL THEN RETURN; END IF;
|
|
341
|
-
|
|
342
|
-
v_tokens := array_remove(string_to_array(v_q, ' '), '');
|
|
343
|
-
p_limit := least(greatest(coalesce(p_limit, 30), 1), 200);
|
|
344
|
-
p_per_source := least(greatest(coalesce(p_per_source, 8), 1), 50);
|
|
345
|
-
IF length(v_digits) < 4 THEN v_digits := ''; END IF;
|
|
346
|
-
|
|
347
|
-
IF v_has_trgm THEN
|
|
348
|
-
BEGIN
|
|
349
|
-
-- Touching a pg_trgm function loads the library, which is what registers
|
|
350
|
-
-- its GUCs; before that they are unrecognized placeholders only a
|
|
351
|
-
-- superuser may set. pg_trgm ships word_similarity_threshold at 0.6,
|
|
352
|
-
-- which forgives a wrong letter but not the transposition people
|
|
353
|
-
-- actually type ("bigdoinho"). LOCAL, so nothing outside this call
|
|
354
|
-
-- inherits a looser notion of "close" — and best-effort, because a pool
|
|
355
|
-
-- that refuses the setting should still search, just less forgivingly.
|
|
356
|
-
PERFORM similarity('a', 'a');
|
|
357
|
-
PERFORM set_config('pg_trgm.word_similarity_threshold', '0.40', true);
|
|
358
|
-
EXCEPTION WHEN OTHERS THEN NULL;
|
|
359
|
-
END;
|
|
360
|
-
END IF;
|
|
361
|
-
|
|
362
|
-
-- Two phases. Phase 1 is literal: every token must appear, which is exactly
|
|
363
|
-
-- what the trigram index answers, and it is what the user meant 99 times out
|
|
364
|
-
-- of 100. Phase 2 only runs when phase 1 found NOTHING, and only then pays
|
|
365
|
-
-- for fuzzy matching. ORing the two would have made every ordinary query
|
|
366
|
-
-- carry the cost — and would have let near-misses dilute exact answers.
|
|
367
|
-
<<phases>>
|
|
368
|
-
FOR v_phase IN 1..2 LOOP
|
|
369
|
-
EXIT WHEN v_phase = 2 AND (
|
|
370
|
-
coalesce(array_length(v_acc, 1), 0) > 0 OR NOT v_has_trgm OR length(v_q) < 4
|
|
371
|
-
);
|
|
372
|
-
|
|
373
|
-
FOR v_src IN
|
|
374
|
-
SELECT s.* FROM public.fayz_search_sources s
|
|
375
|
-
WHERE s.enabled
|
|
376
|
-
AND to_regclass(s.relation) IS NOT NULL
|
|
377
|
-
AND (
|
|
378
|
-
p_entity_keys IS NULL
|
|
379
|
-
OR s.entity_key = ANY (p_entity_keys)
|
|
380
|
-
OR (s.kind_column IS NOT NULL AND EXISTS (
|
|
381
|
-
SELECT 1 FROM unnest(p_entity_keys) k WHERE k LIKE s.entity_key || ':%'))
|
|
382
|
-
)
|
|
383
|
-
ORDER BY s.weight DESC, s.entity_key
|
|
384
|
-
LOOP
|
|
385
|
-
v_title := 'public.fayz_norm(' || v_src.title_expr || ')';
|
|
386
|
-
v_hay := 'public.fayz_norm(' || v_src.haystack_expr || ')';
|
|
387
|
-
|
|
388
|
-
IF v_phase = 1 THEN
|
|
389
|
-
-- Every token must land somewhere. Emitted as separate literal LIKEs (not
|
|
390
|
-
-- `LIKE ALL (array)`) because that is the only form the planner can push
|
|
391
|
-
-- into the trigram index. The tokens came out of fayz_norm, so they are
|
|
392
|
-
-- already [a-z0-9 ] — quote_literal is belt and braces.
|
|
393
|
-
v_where := '';
|
|
394
|
-
FOREACH v_token IN ARRAY v_tokens LOOP
|
|
395
|
-
IF v_where <> '' THEN v_where := v_where || ' AND '; END IF;
|
|
396
|
-
v_where := v_where || v_hay || ' LIKE ' || quote_literal('%' || v_token || '%');
|
|
397
|
-
END LOOP;
|
|
398
|
-
v_match := '(' || v_where || ')';
|
|
399
|
-
|
|
400
|
-
IF v_digits <> '' AND v_src.digits_expr IS NOT NULL THEN
|
|
401
|
-
v_match := v_match || ' OR public.fayz_digits(' || v_src.digits_expr || ') LIKE '
|
|
402
|
-
|| quote_literal('%' || v_digits || '%');
|
|
403
|
-
END IF;
|
|
404
|
-
ELSE
|
|
405
|
-
-- `<%` (word_similarity) rather than `%` (similarity): the right-hand
|
|
406
|
-
-- side is a whole haystack, and plain similarity of a 7-letter query
|
|
407
|
-
-- against a 200-character row is always below any usable threshold.
|
|
408
|
-
-- Measured against the INDEXED expression so phase 2 is an index scan too.
|
|
409
|
-
v_match := quote_literal(v_q) || ' <% ' || v_hay;
|
|
410
|
-
END IF;
|
|
411
|
-
|
|
412
|
-
-- Archetype sources emit one key per kind. When the caller asked for a
|
|
413
|
-
-- subset of kinds, push that INTO the source query: filtering after a
|
|
414
|
-
-- per-source LIMIT would let a populous kind starve the one being asked for.
|
|
415
|
-
v_key := quote_literal(v_src.entity_key);
|
|
416
|
-
IF v_src.kind_column IS NOT NULL THEN
|
|
417
|
-
v_key := v_key || ' || '':'' || ' || quote_ident(v_src.kind_column);
|
|
418
|
-
IF p_entity_keys IS NOT NULL AND NOT (v_src.entity_key = ANY (p_entity_keys)) THEN
|
|
419
|
-
SELECT array_agg(substr(k, length(v_src.entity_key) + 2))
|
|
420
|
-
INTO v_kinds
|
|
421
|
-
FROM unnest(p_entity_keys) k
|
|
422
|
-
WHERE k LIKE v_src.entity_key || ':%';
|
|
423
|
-
IF v_kinds IS NOT NULL THEN
|
|
424
|
-
v_match := '(' || v_match || ') AND ' || quote_ident(v_src.kind_column)
|
|
425
|
-
|| ' = ANY (' || quote_literal(v_kinds::text) || '::text[])';
|
|
426
|
-
END IF;
|
|
427
|
-
END IF;
|
|
428
|
-
END IF;
|
|
429
|
-
|
|
430
|
-
v_sql :=
|
|
431
|
-
'SELECT ' || v_key || '::text AS entity_key, '
|
|
432
|
-
|| quote_ident(v_src.id_column) || '::text AS record_id, '
|
|
433
|
-
|| '(' || v_src.title_expr || ')::text AS title, '
|
|
434
|
-
|| '(' || coalesce(v_src.subtitle_expr, 'NULL') || ')::text AS subtitle, '
|
|
435
|
-
-- Rank ladder, mirrored by scoreCandidate() on the client. The client
|
|
436
|
-
-- re-scores what it receives; this ordering decides what survives the
|
|
437
|
-
-- per-source LIMIT, which is the decision the client cannot make.
|
|
438
|
-
|| '(GREATEST('
|
|
439
|
-
|| ' CASE WHEN ' || v_title || ' = ' || quote_literal(v_q) || ' THEN 1.00'
|
|
440
|
-
|| ' WHEN ' || v_title || ' LIKE ' || quote_literal(v_q || '%') || ' THEN 0.94'
|
|
441
|
-
|| ' WHEN ' || v_title || ' LIKE ' || quote_literal('% ' || v_q || '%') || ' THEN 0.88'
|
|
442
|
-
|| ' WHEN ' || v_title || ' LIKE ' || quote_literal('%' || v_q || '%') || ' THEN 0.76'
|
|
443
|
-
|| ' WHEN ' || v_hay || ' LIKE ' || quote_literal('%' || v_q || '%') || ' THEN 0.62'
|
|
444
|
-
|| ' ELSE 0.40 END'
|
|
445
|
-
|| CASE WHEN v_has_trgm
|
|
446
|
-
THEN ', word_similarity(' || quote_literal(v_q) || ', ' || v_title || ')::numeric * 0.60'
|
|
447
|
-
ELSE '' END
|
|
448
|
-
|| ') * ' || v_src.weight || ')::real AS score'
|
|
449
|
-
|| ' FROM ' || to_regclass(v_src.relation)::text
|
|
450
|
-
|| ' WHERE ' || quote_ident(v_src.tenant_column) || ' = $1'
|
|
451
|
-
|| CASE WHEN v_src.filter_expr IS NULL THEN '' ELSE ' AND (' || v_src.filter_expr || ')' END
|
|
452
|
-
|| ' AND (' || v_match || ')'
|
|
453
|
-
|| ' ORDER BY 5 DESC LIMIT ' || p_per_source;
|
|
454
|
-
|
|
455
|
-
BEGIN
|
|
456
|
-
EXECUTE 'SELECT coalesce(array_agg(x::public.fayz_search_hit), ''{}''::public.fayz_search_hit[]) FROM ('
|
|
457
|
-
|| v_sql || ') x'
|
|
458
|
-
INTO v_rows USING p_tenant_id;
|
|
459
|
-
v_acc := v_acc || v_rows;
|
|
460
|
-
EXCEPTION WHEN OTHERS THEN
|
|
461
|
-
-- One misconfigured source must never blank the whole search box.
|
|
462
|
-
RAISE NOTICE 'fayz search: source % failed: %', v_src.entity_key, SQLERRM;
|
|
463
|
-
END;
|
|
464
|
-
END LOOP;
|
|
465
|
-
END LOOP phases;
|
|
466
|
-
|
|
467
|
-
RETURN QUERY
|
|
468
|
-
SELECT h.entity_key, h.record_id, h.title, h.subtitle, h.score
|
|
469
|
-
FROM unnest(v_acc) h
|
|
470
|
-
ORDER BY h.score DESC, length(coalesce(h.title, '')) ASC, h.title
|
|
471
|
-
LIMIT p_limit;
|
|
472
|
-
END $$;
|
|
473
|
-
|
|
474
|
-
COMMENT ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) IS
|
|
475
|
-
'Global search across every registered source in one round-trip. SECURITY INVOKER — RLS decides visibility; p_tenant_id only narrows. p_entity_keys restricts to the keys the caller is allowed to see.';
|
|
476
|
-
|
|
477
|
-
REVOKE ALL ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) FROM PUBLIC;
|
|
478
|
-
REVOKE ALL ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) FROM anon;
|
|
479
|
-
GRANT EXECUTE ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) TO authenticated;
|
|
480
|
-
|
|
481
|
-
GRANT EXECUTE ON FUNCTION public.fayz_norm(text) TO authenticated, anon;
|
|
482
|
-
GRANT EXECUTE ON FUNCTION public.fayz_digits(text) TO authenticated, anon;
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
-- 019: Person-first team model — link a membership (login + RBAC role) to the
|
|
2
|
-
-- person record it represents.
|
|
3
|
-
--
|
|
4
|
-
-- A "team member" is a PERSON (public.people, kind in the app's team.personKinds).
|
|
5
|
-
-- public.tenant_members is the OPTIONAL access overlay: a row exists only when a
|
|
6
|
-
-- person is granted login + role. person_id ties the two. Nullable both ways:
|
|
7
|
-
-- * login-only account (e.g. the owner who signed up) -> tenant_members with person_id NULL
|
|
8
|
-
-- * team person without access (e.g. a teacher who never logs in) -> people row, NO tenant_members row
|
|
9
|
-
-- Idempotent + additive (safe to re-apply).
|
|
10
|
-
|
|
11
|
-
ALTER TABLE public.tenant_members
|
|
12
|
-
ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
|
|
13
|
-
|
|
14
|
-
-- A person maps to at most one membership per tenant (partial: many NULLs allowed).
|
|
15
|
-
CREATE UNIQUE INDEX IF NOT EXISTS tenant_members_tenant_person_uidx
|
|
16
|
-
ON public.tenant_members (tenant_id, person_id)
|
|
17
|
-
WHERE person_id IS NOT NULL;
|
|
18
|
-
|
|
19
|
-
CREATE INDEX IF NOT EXISTS tenant_members_person_idx
|
|
20
|
-
ON public.tenant_members (person_id);
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
-- 020_fayz_projects.sql — tenant ↔ fayz project association
|
|
2
|
-
-- ----------------------------------------------------------------------------
|
|
3
|
-
-- Almost every tenant owns one or more "fayz projects": deployed sites/apps on
|
|
4
|
-
-- the fayz platform, each addressed by a platform projectId. A tenant's client
|
|
5
|
-
-- site (exposed on the web by fayz) is one such project; an e-commerce client's
|
|
6
|
-
-- site is a project with the storefront module active — i.e. a sales channel.
|
|
7
|
-
--
|
|
8
|
-
-- This table lives in the CLIENT pool (e.g. cluster-ecommerce) and gives the
|
|
9
|
-
-- admin SDK apps a mature link: admin app ↔ tenant ↔ fayz project. It drives the
|
|
10
|
-
-- "Seu site" widget in the sidebar (open preview / open editor), the Loja Online
|
|
11
|
-
-- sales channel (= the storefront project), and Marketing landing-page links.
|
|
12
|
-
--
|
|
13
|
-
-- No platform integration yet: rows are maintained by the admin (manual CRUD),
|
|
14
|
-
-- and the preview/editor URLs are stored explicitly (there is no URL convention
|
|
15
|
-
-- in code and the platform domains are still undecided). A future platform sync
|
|
16
|
-
-- can populate fayz_project_id + URLs automatically.
|
|
17
|
-
--
|
|
18
|
-
-- Additive + idempotent (CREATE TABLE IF NOT EXISTS).
|
|
19
|
-
|
|
20
|
-
CREATE TABLE IF NOT EXISTS public.fayz_projects (
|
|
21
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
22
|
-
tenant_id uuid NOT NULL,
|
|
23
|
-
-- The fayz platform project id (addresses /public/projects/{id}/…). Text
|
|
24
|
-
-- because the platform issues its own ids; nullable until provisioned.
|
|
25
|
-
fayz_project_id text,
|
|
26
|
-
-- App manifest id of the deployed app (e.g. 'artorious-shop'), when known.
|
|
27
|
-
app_id text,
|
|
28
|
-
name text NOT NULL,
|
|
29
|
-
-- site | storefront | landing_page | other
|
|
30
|
-
kind text NOT NULL DEFAULT 'site',
|
|
31
|
-
-- The tenant's default site (the one the "Seu site" widget shows). Partial
|
|
32
|
-
-- unique index below keeps at most one default per tenant.
|
|
33
|
-
is_default boolean NOT NULL DEFAULT false,
|
|
34
|
-
-- Whether the storefront module is active on this site. A storefront-enabled
|
|
35
|
-
-- site is an e-commerce sales channel (the Loja Online channel).
|
|
36
|
-
storefront_enabled boolean NOT NULL DEFAULT false,
|
|
37
|
-
-- Explicit public URLs. preview_url = the live/preview site; editor_url = the
|
|
38
|
-
-- fayz editor (/fayz/editor). Stored, not derived (no convention in code yet).
|
|
39
|
-
preview_url text,
|
|
40
|
-
editor_url text,
|
|
41
|
-
slug text,
|
|
42
|
-
metadata jsonb NOT NULL DEFAULT '{}',
|
|
43
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
44
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
CREATE INDEX IF NOT EXISTS fayz_projects_tenant_idx ON public.fayz_projects (tenant_id);
|
|
48
|
-
-- At most one default project per tenant.
|
|
49
|
-
CREATE UNIQUE INDEX IF NOT EXISTS fayz_projects_one_default_per_tenant
|
|
50
|
-
ON public.fayz_projects (tenant_id) WHERE is_default;
|
|
51
|
-
|
|
52
|
-
DROP TRIGGER IF EXISTS fayz_projects_updated_at ON public.fayz_projects;
|
|
53
|
-
CREATE TRIGGER fayz_projects_updated_at BEFORE UPDATE ON public.fayz_projects
|
|
54
|
-
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
55
|
-
|
|
56
|
-
ALTER TABLE public.fayz_projects ENABLE ROW LEVEL SECURITY;
|
|
57
|
-
|
|
58
|
-
DROP POLICY IF EXISTS "fayz_projects_member_all" ON public.fayz_projects;
|
|
59
|
-
CREATE POLICY "fayz_projects_member_all" ON public.fayz_projects FOR ALL TO authenticated
|
|
60
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
61
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
62
|
-
|
|
63
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fayz_projects TO authenticated, service_role;
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 021_tenant_roles.sql — custom access profiles, per tenant.
|
|
3
|
-
--
|
|
4
|
-
-- The SDK has always been able to BUILD a custom role (duplicate a system role
|
|
5
|
-
-- in Settings → Permissions, or ask the assistant for one), but the table it
|
|
6
|
-
-- writes to never existed outside one legacy pool: `saas_core.tenant_roles`,
|
|
7
|
-
-- swept into `public` by 0000b and created nowhere else. Everywhere else the
|
|
8
|
-
-- insert threw and the screen swallowed it — a "Criar perfil" button that did
|
|
9
|
-
-- nothing.
|
|
10
|
-
--
|
|
11
|
-
-- Two changes, both required for a custom profile to survive a reload:
|
|
12
|
-
--
|
|
13
|
-
-- 1. public.tenant_roles — the role's IDENTITY (key + name). Its grants live
|
|
14
|
-
-- in tenant_role_overrides, keyed by that same `key`.
|
|
15
|
-
--
|
|
16
|
-
-- 2. tenant_role_overrides.permission_id loses its FK to public.permissions.
|
|
17
|
-
-- That catalog is a legacy of DB-driven RBAC; the feature catalog is now
|
|
18
|
-
-- DECLARED IN CODE by plugins (`declaredFeatures`) and merged at runtime,
|
|
19
|
-
-- so an app can grant `crm.leads.read` on a pool whose permissions table
|
|
20
|
-
-- never heard of plugin-crm. The FK made every such grant fail. The column
|
|
21
|
-
-- stays text in `feature.action` form — the shape agent_guard reads.
|
|
22
|
-
--
|
|
23
|
-
-- Idempotent: safe to re-run, no-ops when already applied.
|
|
24
|
-
-- ============================================================================
|
|
25
|
-
|
|
26
|
-
CREATE TABLE IF NOT EXISTS public.tenant_roles (
|
|
27
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
28
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
29
|
-
-- Stable slug the grants and tenant_members.role point at. Renaming the
|
|
30
|
-
-- profile must never orphan its permissions, so the key is not the name.
|
|
31
|
-
key text NOT NULL,
|
|
32
|
-
name text NOT NULL,
|
|
33
|
-
description text,
|
|
34
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
35
|
-
UNIQUE(tenant_id, key)
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
ALTER TABLE public.tenant_roles ENABLE ROW LEVEL SECURITY;
|
|
39
|
-
|
|
40
|
-
-- Members see their own tenant's roles (the permission screen lists them);
|
|
41
|
-
-- only tenant admins create, rename or delete one.
|
|
42
|
-
DROP POLICY IF EXISTS "tenant_roles_select" ON public.tenant_roles;
|
|
43
|
-
CREATE POLICY "tenant_roles_select" ON public.tenant_roles FOR SELECT
|
|
44
|
-
USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
|
|
45
|
-
|
|
46
|
-
DROP POLICY IF EXISTS "tenant_roles_manage" ON public.tenant_roles;
|
|
47
|
-
CREATE POLICY "tenant_roles_manage" ON public.tenant_roles FOR ALL
|
|
48
|
-
USING (public.is_tenant_admin(tenant_id));
|
|
49
|
-
|
|
50
|
-
GRANT ALL ON public.tenant_roles TO authenticated, service_role;
|
|
51
|
-
|
|
52
|
-
-- The FK, dropped by lookup rather than by name: pools created before the
|
|
53
|
-
-- rename carry the constraint under whatever name Postgres minted.
|
|
54
|
-
DO $$
|
|
55
|
-
DECLARE
|
|
56
|
-
c record;
|
|
57
|
-
BEGIN
|
|
58
|
-
IF to_regclass('public.tenant_role_overrides') IS NULL THEN
|
|
59
|
-
RETURN;
|
|
60
|
-
END IF;
|
|
61
|
-
FOR c IN
|
|
62
|
-
SELECT con.conname
|
|
63
|
-
FROM pg_constraint con
|
|
64
|
-
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
65
|
-
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
|
66
|
-
JOIN pg_class frel ON frel.oid = con.confrelid
|
|
67
|
-
WHERE nsp.nspname = 'public'
|
|
68
|
-
AND rel.relname = 'tenant_role_overrides'
|
|
69
|
-
AND con.contype = 'f'
|
|
70
|
-
AND frel.relname = 'permissions'
|
|
71
|
-
LOOP
|
|
72
|
-
EXECUTE format('ALTER TABLE public.tenant_role_overrides DROP CONSTRAINT %I', c.conname);
|
|
73
|
-
END LOOP;
|
|
74
|
-
END $$;
|