@fayz-ai/db 0.11.0 → 0.13.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 +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +6 -5
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 075_company_legal_entities.sql — the Empresa (legal entity) a unit operates
|
|
3
|
+
-- under (#138, PRD 02 R2, default (c)).
|
|
4
|
+
--
|
|
5
|
+
-- Why this name. PRD 02 R2 left it open ("tabela core `companies`, nome a
|
|
6
|
+
-- definir com o SDK; glossário Empresa"). The bare `companies` is taken: in V1
|
|
7
|
+
-- one CNPJ IS one company IS one unit, and that row maps to app.units — calling
|
|
8
|
+
-- this table `companies` would collide with the concept every V1 reader already
|
|
9
|
+
-- has. `company_legal_entities` keeps the word the reviewer asked for and says
|
|
10
|
+
-- exactly what the row is: the legal person (CNPJ/CPF) a unit operates under.
|
|
11
|
+
--
|
|
12
|
+
-- public.company_legal_entities is the company (CNPJ) or individual (CPF) that
|
|
13
|
+
-- a unit operates under: legal name, trade name, document, registrations, tax
|
|
14
|
+
-- regime, fiscal address, contact, bank data (contract only — no payment
|
|
15
|
+
-- provider is wired here). One legal entity may serve many units (V2, 1:n); V1
|
|
16
|
+
-- arrives 1:1 (companies → units + company_legal_entities).
|
|
17
|
+
-- app.units.company_legal_entity_id is the reference, ON DELETE SET NULL,
|
|
18
|
+
-- composite over (tenant_id, id) so a unit can never point at another tenant's
|
|
19
|
+
-- company. NULL is allowed only while a unit is a draft: a CHECK would break
|
|
20
|
+
-- every existing unit, so the invariant is observable instead —
|
|
21
|
+
-- public.v_units_without_company_legal_entity lists the drafts and the
|
|
22
|
+
-- migration/onboarding closes them.
|
|
23
|
+
--
|
|
24
|
+
-- The table is a business table of the 'tenancy' plugin: scaffolded with the
|
|
25
|
+
-- ONE RLS template (app.scaffold_table, enforce mode; owner scoping off), so a
|
|
26
|
+
-- legal entity is visible to members who reach its unit (unit_id = the unit it
|
|
27
|
+
-- is registered at — the parent of the units that share it) with tenancy.read,
|
|
28
|
+
-- and everything is visible to an all_units member. Permission keys
|
|
29
|
+
-- tenancy.read/create/edit/delete/manage are registered here and added to the
|
|
30
|
+
-- vertical templates: every role reads (a receipt needs the CNPJ), managers and
|
|
31
|
+
-- admins manage. Existing tenants are re-seeded from their recorded template.
|
|
32
|
+
-- The keys stay `tenancy.*`: they name the capability, not the table.
|
|
33
|
+
--
|
|
34
|
+
-- Next (not here): the seven inline fiscal-address columns should move to the
|
|
35
|
+
-- polymorphic public.addresses (017), with owner_type gaining
|
|
36
|
+
-- 'company_legal_entity'. Queued separately — see
|
|
37
|
+
-- docs/data/phase-0/02-tenancy-units-company.md §1.
|
|
38
|
+
-- ============================================================================
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS public.company_legal_entities (
|
|
41
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
42
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
43
|
+
kind text NOT NULL DEFAULT 'company' CHECK (kind IN ('company', 'individual')),
|
|
44
|
+
legal_name text NOT NULL,
|
|
45
|
+
trade_name text,
|
|
46
|
+
document_number text, -- CNPJ (14 digits) or CPF (11 digits), digits only
|
|
47
|
+
state_registration text,
|
|
48
|
+
municipal_registration text,
|
|
49
|
+
tax_regime text, -- simples_nacional | lucro_presumido | lucro_real | mei | other (free text, validated by the app)
|
|
50
|
+
address text,
|
|
51
|
+
address_number text,
|
|
52
|
+
address_complement text,
|
|
53
|
+
district text,
|
|
54
|
+
city text,
|
|
55
|
+
state text,
|
|
56
|
+
postal_code text,
|
|
57
|
+
country text NOT NULL DEFAULT 'BR',
|
|
58
|
+
email text,
|
|
59
|
+
phone text,
|
|
60
|
+
bank_data jsonb NOT NULL DEFAULT '{}'::jsonb, -- {bank_code, agency, account, account_type, pix_key}
|
|
61
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
62
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
63
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
64
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
65
|
+
);
|
|
66
|
+
DO $$
|
|
67
|
+
BEGIN
|
|
68
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_legal_entities_document_number_check' AND conrelid = 'public.company_legal_entities'::regclass) THEN
|
|
69
|
+
ALTER TABLE public.company_legal_entities
|
|
70
|
+
ADD CONSTRAINT company_legal_entities_document_number_check
|
|
71
|
+
CHECK (document_number IS NULL OR document_number ~ '^[0-9]{11}$' OR document_number ~ '^[0-9]{14}$');
|
|
72
|
+
END IF;
|
|
73
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_legal_entities_kind_document_check' AND conrelid = 'public.company_legal_entities'::regclass) THEN
|
|
74
|
+
ALTER TABLE public.company_legal_entities
|
|
75
|
+
ADD CONSTRAINT company_legal_entities_kind_document_check
|
|
76
|
+
CHECK (document_number IS NULL
|
|
77
|
+
OR (kind = 'company' AND length(document_number) = 14)
|
|
78
|
+
OR (kind = 'individual' AND length(document_number) = 11));
|
|
79
|
+
END IF;
|
|
80
|
+
END $$;
|
|
81
|
+
-- one document per tenant (a company may still serve many units)
|
|
82
|
+
CREATE UNIQUE INDEX IF NOT EXISTS company_legal_entities_tenant_document_uidx
|
|
83
|
+
ON public.company_legal_entities (tenant_id, document_number) WHERE document_number IS NOT NULL;
|
|
84
|
+
|
|
85
|
+
-- digits only, whatever the client typed
|
|
86
|
+
CREATE OR REPLACE FUNCTION public.trg_company_legal_entities_normalize()
|
|
87
|
+
RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
|
|
88
|
+
BEGIN
|
|
89
|
+
NEW.document_number := nullif(regexp_replace(coalesce(NEW.document_number, ''), '[^0-9]', '', 'g'), '');
|
|
90
|
+
NEW.postal_code := nullif(regexp_replace(coalesce(NEW.postal_code, ''), '[^0-9A-Za-z-]', '', 'g'), '');
|
|
91
|
+
NEW.legal_name := btrim(NEW.legal_name);
|
|
92
|
+
IF NEW.legal_name = '' THEN
|
|
93
|
+
RAISE EXCEPTION 'company_legal_entities: legal_name is required' USING ERRCODE = '22023';
|
|
94
|
+
END IF;
|
|
95
|
+
RETURN NEW;
|
|
96
|
+
END $$;
|
|
97
|
+
REVOKE ALL ON FUNCTION public.trg_company_legal_entities_normalize() FROM public, anon;
|
|
98
|
+
DROP TRIGGER IF EXISTS company_legal_entities_normalize ON public.company_legal_entities;
|
|
99
|
+
CREATE TRIGGER company_legal_entities_normalize BEFORE INSERT OR UPDATE ON public.company_legal_entities
|
|
100
|
+
FOR EACH ROW EXECUTE FUNCTION public.trg_company_legal_entities_normalize();
|
|
101
|
+
|
|
102
|
+
-- ── permission keys + templates ─────────────────────────────────────────
|
|
103
|
+
SELECT app.seed_permissions('[
|
|
104
|
+
{"key":"tenancy.read","plugin":"tenancy","description":"Read legal entities (Empresa) of the units in scope"},
|
|
105
|
+
{"key":"tenancy.create","plugin":"tenancy","description":"Create a legal entity"},
|
|
106
|
+
{"key":"tenancy.edit","plugin":"tenancy","description":"Edit a legal entity"},
|
|
107
|
+
{"key":"tenancy.delete","plugin":"tenancy","description":"Delete a legal entity"},
|
|
108
|
+
{"key":"tenancy.manage","plugin":"tenancy","description":"Manage legal entities and their unit links"}
|
|
109
|
+
]'::jsonb);
|
|
110
|
+
-- every role reads; managers/admins manage
|
|
111
|
+
SELECT app.seed_role_template_rows('generic', 'admin', 'Administrator', 1, ARRAY['tenancy.manage']);
|
|
112
|
+
SELECT app.seed_role_template_rows('generic', 'manager', 'Manager', 2, ARRAY['tenancy.manage']);
|
|
113
|
+
SELECT app.seed_role_template_rows('generic', 'staff', 'Staff', 3, ARRAY['tenancy.read']);
|
|
114
|
+
SELECT app.seed_role_template_rows('generic', 'viewer', 'Viewer', 4, ARRAY['tenancy.read']);
|
|
115
|
+
SELECT app.seed_role_template_rows('salon', 'manager', 'Gerente', 1, ARRAY['tenancy.manage']);
|
|
116
|
+
SELECT app.seed_role_template_rows('salon', 'reception', 'Recepção', 2, ARRAY['tenancy.read']);
|
|
117
|
+
SELECT app.seed_role_template_rows('salon', 'professional', 'Profissional', 3, ARRAY['tenancy.read']);
|
|
118
|
+
SELECT app.seed_role_template_rows('salon', 'financial', 'Financeiro', 4, ARRAY['tenancy.read']);
|
|
119
|
+
SELECT app.seed_role_template_rows('clinic', 'manager', 'Coordenação', 1, ARRAY['tenancy.manage']);
|
|
120
|
+
SELECT app.seed_role_template_rows('clinic', 'reception', 'Recepção', 2, ARRAY['tenancy.read']);
|
|
121
|
+
SELECT app.seed_role_template_rows('clinic', 'professional', 'Profissional', 3, ARRAY['tenancy.read']);
|
|
122
|
+
SELECT app.seed_role_template_rows('clinic', 'financial', 'Financeiro', 4, ARRAY['tenancy.read']);
|
|
123
|
+
SELECT app.seed_role_template_rows('restaurant', 'manager', 'Gerente', 1, ARRAY['tenancy.manage']);
|
|
124
|
+
SELECT app.seed_role_template_rows('restaurant', 'cashier', 'Caixa', 2, ARRAY['tenancy.read']);
|
|
125
|
+
SELECT app.seed_role_template_rows('restaurant', 'waiter', 'Garçom', 3, ARRAY['tenancy.read']);
|
|
126
|
+
SELECT app.seed_role_template_rows('restaurant', 'kitchen', 'Cozinha', 4, ARRAY['tenancy.read']);
|
|
127
|
+
-- tenants already seeded from a template receive the new keys (idempotent)
|
|
128
|
+
SELECT app.seed_role_template(t.id, t.role_template)
|
|
129
|
+
FROM public.tenants t
|
|
130
|
+
WHERE t.role_template IS NOT NULL
|
|
131
|
+
AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = t.role_template);
|
|
132
|
+
|
|
133
|
+
-- ── the scaffold: columns, composite FKs, indexes, forced RLS, the 4 policies
|
|
134
|
+
SELECT app.scaffold_table('public.company_legal_entities', 'tenancy.company_legal_entity', 'tenancy', false, 'enforce');
|
|
135
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.company_legal_entities TO authenticated;
|
|
136
|
+
GRANT ALL ON public.company_legal_entities TO service_role;
|
|
137
|
+
REVOKE ALL ON public.company_legal_entities FROM anon;
|
|
138
|
+
|
|
139
|
+
-- ── the unit → company legal entity reference ───────────────────────────
|
|
140
|
+
DO $$
|
|
141
|
+
BEGIN
|
|
142
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'units_company_legal_entity_fk' AND conrelid = 'app.units'::regclass) THEN
|
|
143
|
+
ALTER TABLE app.units
|
|
144
|
+
ADD CONSTRAINT units_company_legal_entity_fk
|
|
145
|
+
FOREIGN KEY (tenant_id, company_legal_entity_id) REFERENCES public.company_legal_entities (tenant_id, id)
|
|
146
|
+
ON DELETE SET NULL (company_legal_entity_id);
|
|
147
|
+
END IF;
|
|
148
|
+
END $$;
|
|
149
|
+
CREATE INDEX IF NOT EXISTS units_company_legal_entity_idx ON app.units (tenant_id, company_legal_entity_id) WHERE company_legal_entity_id IS NOT NULL;
|
|
150
|
+
|
|
151
|
+
-- Drafts: active units that still point at no company legal entity (the
|
|
152
|
+
-- invariant a CHECK cannot enforce yet). security_invoker: app.units RLS applies.
|
|
153
|
+
CREATE OR REPLACE VIEW public.v_units_without_company_legal_entity WITH (security_invoker = true) AS
|
|
154
|
+
SELECT u.tenant_id, u.id AS unit_id, u.kind, u.name, u.parent_unit_id, u.created_at
|
|
155
|
+
FROM app.units u
|
|
156
|
+
WHERE u.active AND u.company_legal_entity_id IS NULL;
|
|
157
|
+
GRANT SELECT ON public.v_units_without_company_legal_entity TO authenticated, service_role;
|
|
158
|
+
|
|
159
|
+
-- Consolidation: every unit with its company and its parent (the network view:
|
|
160
|
+
-- group by parent_unit_id gives "the franchisee's companies").
|
|
161
|
+
CREATE OR REPLACE VIEW public.v_unit_company_legal_entities WITH (security_invoker = true) AS
|
|
162
|
+
SELECT u.tenant_id, u.id AS unit_id, u.kind AS unit_kind, u.name AS unit_name, u.parent_unit_id,
|
|
163
|
+
cle.id AS company_legal_entity_id, cle.kind AS company_legal_entity_kind,
|
|
164
|
+
cle.legal_name, cle.trade_name, cle.document_number,
|
|
165
|
+
cle.city, cle.state, cle.is_active AS company_legal_entity_active
|
|
166
|
+
FROM app.units u
|
|
167
|
+
LEFT JOIN public.company_legal_entities cle
|
|
168
|
+
ON cle.tenant_id = u.tenant_id AND cle.id = u.company_legal_entity_id;
|
|
169
|
+
GRANT SELECT ON public.v_unit_company_legal_entities TO authenticated, service_role;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 076_tenant_settings.sql — tenant settings by key (#138, PRD 02 R4/R5, default (g)).
|
|
3
|
+
--
|
|
4
|
+
-- V1's general_settings is a bag of columns from every layer. V2 keeps
|
|
5
|
+
-- tenants.settings (jsonb) as the store and adds a REGISTRY of what may live in
|
|
6
|
+
-- it: app.tenant_setting_keys (key, type, default, description, reader, scope).
|
|
7
|
+
-- Core keys are seeded here; a plugin registers its own with reader
|
|
8
|
+
-- 'plugin:<id>' from its migration (app.seed_setting_keys). A key that is not
|
|
9
|
+
-- registered cannot be written through the RPC, and reads of unknown keys are
|
|
10
|
+
-- refused — the registry is the contract. `permissions_enforcement_enabled` is
|
|
11
|
+
-- deliberately absent (RLS is the law; PRD 02 R4).
|
|
12
|
+
--
|
|
13
|
+
-- Dotted keys are nested paths in the jsonb: 'branding.logo_url' lives at
|
|
14
|
+
-- settings -> 'branding' -> 'logo_url' — the shape the admin adapter already
|
|
15
|
+
-- writes ({branding: {}}). Legacy free-form keys already in tenants.settings
|
|
16
|
+
-- are tolerated (nothing deletes them); only the RPC surface is governed.
|
|
17
|
+
--
|
|
18
|
+
-- RPCs (SECURITY DEFINER, search_path '', tenant = app.current_tenant_id()):
|
|
19
|
+
-- tenant_setting(key) → jsonb effective value (set value, else default);
|
|
20
|
+
-- any active member may read (the UI
|
|
21
|
+
-- needs locale/timezone before it renders
|
|
22
|
+
-- a settings screen); unknown key → 22023
|
|
23
|
+
-- tenant_settings() → jsonb {key: effective value} for every registered key
|
|
24
|
+
-- tenant_setting_set(key, value) → jsonb settings.manage; type-checked; JSON null
|
|
25
|
+
-- clears the key (default applies again)
|
|
26
|
+
-- tenant_branding() → jsonb {tenant_id, name, logo_url, primary_color,
|
|
27
|
+
-- custom_domain} for the current tenant;
|
|
28
|
+
-- NEVER raises — defaults on any error
|
|
29
|
+
-- (US-PLT-ADM-06: branding must not block login)
|
|
30
|
+
-- tenant_branding_public(slug, domain) → jsonb the same, narrow, by slug or custom
|
|
31
|
+
-- domain, callable by anon for the login
|
|
32
|
+
-- page (a slug is a URL; nothing else leaks)
|
|
33
|
+
--
|
|
34
|
+
-- tenant_domains (V1) has no table: a custom domain is the setting
|
|
35
|
+
-- branding.custom_domain; routing by domain is the platform's job.
|
|
36
|
+
-- ============================================================================
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS app.tenant_setting_keys (
|
|
39
|
+
key text PRIMARY KEY CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$'),
|
|
40
|
+
type text NOT NULL CHECK (type IN ('string', 'integer', 'number', 'boolean', 'object', 'array')),
|
|
41
|
+
default_value jsonb,
|
|
42
|
+
description text,
|
|
43
|
+
reader text NOT NULL DEFAULT 'core' CHECK (reader = 'core' OR reader ~ '^plugin:[a-z][a-z0-9_-]*$'),
|
|
44
|
+
scope text NOT NULL DEFAULT 'tenant' CHECK (scope IN ('tenant', 'unit')),
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
47
|
+
);
|
|
48
|
+
DROP TRIGGER IF EXISTS tenant_setting_keys_updated_at ON app.tenant_setting_keys;
|
|
49
|
+
CREATE TRIGGER tenant_setting_keys_updated_at BEFORE UPDATE ON app.tenant_setting_keys
|
|
50
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
51
|
+
ALTER TABLE app.tenant_setting_keys ENABLE ROW LEVEL SECURITY;
|
|
52
|
+
GRANT SELECT ON app.tenant_setting_keys TO authenticated;
|
|
53
|
+
GRANT ALL ON app.tenant_setting_keys TO service_role;
|
|
54
|
+
REVOKE ALL ON app.tenant_setting_keys FROM anon;
|
|
55
|
+
DROP POLICY IF EXISTS tenant_setting_keys_select ON app.tenant_setting_keys;
|
|
56
|
+
CREATE POLICY tenant_setting_keys_select ON app.tenant_setting_keys FOR SELECT TO authenticated USING (true);
|
|
57
|
+
|
|
58
|
+
CREATE OR REPLACE FUNCTION app.seed_setting_keys(p_rows jsonb)
|
|
59
|
+
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
60
|
+
DECLARE v_n integer;
|
|
61
|
+
BEGIN
|
|
62
|
+
WITH ins AS (
|
|
63
|
+
INSERT INTO app.tenant_setting_keys (key, type, default_value, description, reader, scope)
|
|
64
|
+
SELECT r ->> 'key', r ->> 'type', r -> 'default', r ->> 'description',
|
|
65
|
+
coalesce(r ->> 'reader', 'core'), coalesce(r ->> 'scope', 'tenant')
|
|
66
|
+
FROM jsonb_array_elements(p_rows) r
|
|
67
|
+
ON CONFLICT (key) DO UPDATE
|
|
68
|
+
SET type = EXCLUDED.type, default_value = EXCLUDED.default_value,
|
|
69
|
+
description = coalesce(EXCLUDED.description, app.tenant_setting_keys.description),
|
|
70
|
+
reader = EXCLUDED.reader, scope = EXCLUDED.scope
|
|
71
|
+
RETURNING 1
|
|
72
|
+
) SELECT count(*) INTO v_n FROM ins;
|
|
73
|
+
RETURN v_n;
|
|
74
|
+
END $$;
|
|
75
|
+
REVOKE ALL ON FUNCTION app.seed_setting_keys(jsonb) FROM public, anon, authenticated;
|
|
76
|
+
GRANT EXECUTE ON FUNCTION app.seed_setting_keys(jsonb) TO service_role;
|
|
77
|
+
|
|
78
|
+
-- ── core keys ───────────────────────────────────────────────────────────
|
|
79
|
+
SELECT app.seed_setting_keys('[
|
|
80
|
+
{"key":"locale", "type":"string", "default":"pt-BR", "description":"UI locale of the tenant (BCP 47)"},
|
|
81
|
+
{"key":"timezone", "type":"string", "default":"America/Sao_Paulo", "description":"IANA time zone every date of the tenant is shown in"},
|
|
82
|
+
{"key":"currency", "type":"string", "default":"BRL", "description":"ISO 4217 currency of prices and totals"},
|
|
83
|
+
{"key":"branding.logo_url", "type":"string", "default":null, "description":"Logo shown in the app and on the login page (falls back to tenants.logo_url)"},
|
|
84
|
+
{"key":"branding.primary_color", "type":"string", "default":null, "description":"Primary brand colour (CSS colour)"},
|
|
85
|
+
{"key":"branding.custom_domain", "type":"string", "default":null, "description":"Custom domain of the tenant (white-label); routing is the platform''s"},
|
|
86
|
+
{"key":"branding.app_name", "type":"string", "default":null, "description":"Name shown instead of the product name (falls back to tenants.name)"},
|
|
87
|
+
{"key":"units.selector_enabled", "type":"boolean","default":true, "description":"Show the unit selector to members with more than one unit"}
|
|
88
|
+
]'::jsonb);
|
|
89
|
+
|
|
90
|
+
-- ── helpers ─────────────────────────────────────────────────────────────
|
|
91
|
+
-- jsonb_set that creates the intermediate objects of a dotted path.
|
|
92
|
+
CREATE OR REPLACE FUNCTION app.jsonb_set_path(p_doc jsonb, p_path text[], p_value jsonb)
|
|
93
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE SET search_path = '' AS $$
|
|
94
|
+
DECLARE
|
|
95
|
+
v_head text := p_path[1];
|
|
96
|
+
v_rest text[] := p_path[2:];
|
|
97
|
+
v_doc jsonb := CASE WHEN jsonb_typeof(p_doc) = 'object' THEN p_doc ELSE '{}'::jsonb END;
|
|
98
|
+
BEGIN
|
|
99
|
+
IF cardinality(p_path) = 0 THEN
|
|
100
|
+
RETURN p_value;
|
|
101
|
+
END IF;
|
|
102
|
+
IF cardinality(v_rest) = 0 THEN
|
|
103
|
+
IF p_value IS NULL OR jsonb_typeof(p_value) = 'null' THEN
|
|
104
|
+
RETURN v_doc - v_head;
|
|
105
|
+
END IF;
|
|
106
|
+
RETURN jsonb_set(v_doc, ARRAY[v_head], p_value, true);
|
|
107
|
+
END IF;
|
|
108
|
+
RETURN jsonb_set(v_doc, ARRAY[v_head], app.jsonb_set_path(v_doc -> v_head, v_rest, p_value), true);
|
|
109
|
+
END $$;
|
|
110
|
+
REVOKE ALL ON FUNCTION app.jsonb_set_path(jsonb, text[], jsonb) FROM public, anon;
|
|
111
|
+
GRANT EXECUTE ON FUNCTION app.jsonb_set_path(jsonb, text[], jsonb) TO authenticated, service_role;
|
|
112
|
+
|
|
113
|
+
CREATE OR REPLACE FUNCTION app.setting_type_ok(p_type text, p_value jsonb)
|
|
114
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE SET search_path = '' AS $$
|
|
115
|
+
SELECT CASE
|
|
116
|
+
WHEN p_value IS NULL OR jsonb_typeof(p_value) = 'null' THEN true
|
|
117
|
+
WHEN p_type = 'integer' THEN jsonb_typeof(p_value) = 'number' AND (p_value #>> '{}') ~ '^-?[0-9]+$'
|
|
118
|
+
WHEN p_type = 'number' THEN jsonb_typeof(p_value) = 'number'
|
|
119
|
+
ELSE jsonb_typeof(p_value) = p_type
|
|
120
|
+
END;
|
|
121
|
+
$$;
|
|
122
|
+
REVOKE ALL ON FUNCTION app.setting_type_ok(text, jsonb) FROM public, anon;
|
|
123
|
+
GRANT EXECUTE ON FUNCTION app.setting_type_ok(text, jsonb) TO authenticated, service_role;
|
|
124
|
+
|
|
125
|
+
-- effective value of one key for one tenant (internal; no permission check)
|
|
126
|
+
CREATE OR REPLACE FUNCTION app.tenant_setting_value(p_tenant uuid, p_key text)
|
|
127
|
+
RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
128
|
+
SELECT coalesce(
|
|
129
|
+
(SELECT t.settings #> string_to_array(p_key, '.') FROM public.tenants t WHERE t.id = p_tenant),
|
|
130
|
+
(SELECT k.default_value FROM app.tenant_setting_keys k WHERE k.key = p_key)
|
|
131
|
+
);
|
|
132
|
+
$$;
|
|
133
|
+
REVOKE ALL ON FUNCTION app.tenant_setting_value(uuid, text) FROM public, anon, authenticated;
|
|
134
|
+
|
|
135
|
+
-- ── RPCs ────────────────────────────────────────────────────────────────
|
|
136
|
+
CREATE OR REPLACE FUNCTION public.tenant_setting(p_key text)
|
|
137
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
138
|
+
DECLARE v_tenant uuid := app.current_tenant_id();
|
|
139
|
+
BEGIN
|
|
140
|
+
IF v_tenant IS NULL THEN
|
|
141
|
+
RAISE EXCEPTION 'tenant_setting: no active tenant' USING ERRCODE = '42501';
|
|
142
|
+
END IF;
|
|
143
|
+
IF NOT EXISTS (SELECT 1 FROM app.tenant_setting_keys k WHERE k.key = p_key) THEN
|
|
144
|
+
RAISE EXCEPTION 'tenant_setting: unknown key %', coalesce(p_key, '<null>') USING ERRCODE = '22023';
|
|
145
|
+
END IF;
|
|
146
|
+
RETURN app.tenant_setting_value(v_tenant, p_key);
|
|
147
|
+
END $$;
|
|
148
|
+
REVOKE ALL ON FUNCTION public.tenant_setting(text) FROM public, anon;
|
|
149
|
+
GRANT EXECUTE ON FUNCTION public.tenant_setting(text) TO authenticated, service_role;
|
|
150
|
+
|
|
151
|
+
CREATE OR REPLACE FUNCTION public.tenant_settings()
|
|
152
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
153
|
+
DECLARE v_tenant uuid := app.current_tenant_id();
|
|
154
|
+
BEGIN
|
|
155
|
+
IF v_tenant IS NULL THEN
|
|
156
|
+
RAISE EXCEPTION 'tenant_settings: no active tenant' USING ERRCODE = '42501';
|
|
157
|
+
END IF;
|
|
158
|
+
RETURN coalesce((SELECT jsonb_object_agg(k.key, app.tenant_setting_value(v_tenant, k.key)) FROM app.tenant_setting_keys k), '{}'::jsonb);
|
|
159
|
+
END $$;
|
|
160
|
+
REVOKE ALL ON FUNCTION public.tenant_settings() FROM public, anon;
|
|
161
|
+
GRANT EXECUTE ON FUNCTION public.tenant_settings() TO authenticated, service_role;
|
|
162
|
+
|
|
163
|
+
CREATE OR REPLACE FUNCTION public.tenant_setting_set(p_key text, p_value jsonb)
|
|
164
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
165
|
+
DECLARE
|
|
166
|
+
v_tenant uuid := app.current_tenant_id();
|
|
167
|
+
k record;
|
|
168
|
+
BEGIN
|
|
169
|
+
IF v_tenant IS NULL OR auth.uid() IS NULL THEN
|
|
170
|
+
RAISE EXCEPTION 'tenant_setting_set: no session or no active tenant' USING ERRCODE = '42501';
|
|
171
|
+
END IF;
|
|
172
|
+
IF NOT app.has_permission('settings.manage') THEN
|
|
173
|
+
RAISE EXCEPTION 'tenant_setting_set: settings.manage is required' USING ERRCODE = '42501';
|
|
174
|
+
END IF;
|
|
175
|
+
SELECT * INTO k FROM app.tenant_setting_keys x WHERE x.key = p_key;
|
|
176
|
+
IF NOT FOUND THEN
|
|
177
|
+
RAISE EXCEPTION 'tenant_setting_set: unknown key %', coalesce(p_key, '<null>') USING ERRCODE = '22023';
|
|
178
|
+
END IF;
|
|
179
|
+
IF k.scope <> 'tenant' THEN
|
|
180
|
+
RAISE EXCEPTION 'tenant_setting_set: key % is unit-scoped', p_key USING ERRCODE = '22023';
|
|
181
|
+
END IF;
|
|
182
|
+
IF NOT app.setting_type_ok(k.type, p_value) THEN
|
|
183
|
+
RAISE EXCEPTION 'tenant_setting_set: key % expects %, got %', p_key, k.type, coalesce(jsonb_typeof(p_value), 'null') USING ERRCODE = '22023';
|
|
184
|
+
END IF;
|
|
185
|
+
UPDATE public.tenants t
|
|
186
|
+
SET settings = app.jsonb_set_path(coalesce(t.settings, '{}'::jsonb), string_to_array(p_key, '.'), p_value)
|
|
187
|
+
WHERE t.id = v_tenant;
|
|
188
|
+
RETURN app.tenant_setting_value(v_tenant, p_key);
|
|
189
|
+
END $$;
|
|
190
|
+
REVOKE ALL ON FUNCTION public.tenant_setting_set(text, jsonb) FROM public, anon;
|
|
191
|
+
GRANT EXECUTE ON FUNCTION public.tenant_setting_set(text, jsonb) TO authenticated, service_role;
|
|
192
|
+
|
|
193
|
+
-- ── branding: never raises ──────────────────────────────────────────────
|
|
194
|
+
CREATE OR REPLACE FUNCTION app.branding_of(p_tenant uuid)
|
|
195
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
196
|
+
DECLARE t record; v jsonb;
|
|
197
|
+
BEGIN
|
|
198
|
+
SELECT x.id, x.name, x.slug, x.logo_url, x.settings INTO t FROM public.tenants x WHERE x.id = p_tenant;
|
|
199
|
+
IF t.id IS NULL THEN
|
|
200
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL);
|
|
201
|
+
END IF;
|
|
202
|
+
v := coalesce(t.settings, '{}'::jsonb);
|
|
203
|
+
RETURN jsonb_build_object(
|
|
204
|
+
'tenant_id', t.id,
|
|
205
|
+
'name', coalesce(nullif(v #>> '{branding,app_name}', ''), t.name),
|
|
206
|
+
'slug', t.slug,
|
|
207
|
+
'logo_url', coalesce(nullif(v #>> '{branding,logo_url}', ''), t.logo_url),
|
|
208
|
+
'primary_color', nullif(v #>> '{branding,primary_color}', ''),
|
|
209
|
+
'custom_domain', nullif(v #>> '{branding,custom_domain}', '')
|
|
210
|
+
);
|
|
211
|
+
EXCEPTION WHEN OTHERS THEN
|
|
212
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL,
|
|
213
|
+
'error', 'branding unavailable');
|
|
214
|
+
END $$;
|
|
215
|
+
REVOKE ALL ON FUNCTION app.branding_of(uuid) FROM public, anon, authenticated;
|
|
216
|
+
|
|
217
|
+
CREATE OR REPLACE FUNCTION public.tenant_branding()
|
|
218
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
219
|
+
BEGIN
|
|
220
|
+
RETURN app.branding_of(app.current_tenant_id());
|
|
221
|
+
EXCEPTION WHEN OTHERS THEN
|
|
222
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL,
|
|
223
|
+
'error', 'branding unavailable');
|
|
224
|
+
END $$;
|
|
225
|
+
REVOKE ALL ON FUNCTION public.tenant_branding() FROM public, anon;
|
|
226
|
+
GRANT EXECUTE ON FUNCTION public.tenant_branding() TO authenticated, service_role;
|
|
227
|
+
|
|
228
|
+
-- The login page knows the slug (URL) or the host (custom domain) and nothing
|
|
229
|
+
-- else: it may ask for the branding of that tenant, and only the branding.
|
|
230
|
+
CREATE OR REPLACE FUNCTION public.tenant_branding_public(p_slug text DEFAULT NULL, p_domain text DEFAULT NULL)
|
|
231
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
|
|
232
|
+
DECLARE v_tenant uuid;
|
|
233
|
+
BEGIN
|
|
234
|
+
IF nullif(btrim(coalesce(p_slug, '')), '') IS NOT NULL THEN
|
|
235
|
+
SELECT t.id INTO v_tenant FROM public.tenants t WHERE t.slug = lower(btrim(p_slug));
|
|
236
|
+
ELSIF nullif(btrim(coalesce(p_domain, '')), '') IS NOT NULL THEN
|
|
237
|
+
SELECT t.id INTO v_tenant FROM public.tenants t
|
|
238
|
+
WHERE lower(t.settings #>> '{branding,custom_domain}') = lower(btrim(p_domain))
|
|
239
|
+
ORDER BY t.created_at LIMIT 1;
|
|
240
|
+
END IF;
|
|
241
|
+
RETURN app.branding_of(v_tenant) - 'error';
|
|
242
|
+
EXCEPTION WHEN OTHERS THEN
|
|
243
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'name', NULL, 'slug', NULL, 'logo_url', NULL, 'primary_color', NULL, 'custom_domain', NULL);
|
|
244
|
+
END $$;
|
|
245
|
+
REVOKE ALL ON FUNCTION public.tenant_branding_public(text, text) FROM public;
|
|
246
|
+
GRANT EXECUTE ON FUNCTION public.tenant_branding_public(text, text) TO anon, authenticated, service_role;
|
|
247
|
+
|
|
248
|
+
-- read view of the registry for the UI
|
|
249
|
+
CREATE OR REPLACE VIEW public.v_tenant_setting_keys WITH (security_invoker = true) AS
|
|
250
|
+
SELECT key, type, default_value, description, reader, scope FROM app.tenant_setting_keys;
|
|
251
|
+
GRANT SELECT ON public.v_tenant_setting_keys TO authenticated, service_role;
|