@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,75 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 143_a_professional_has_a_rate.sql — a commission rate belongs to the hat, not
|
|
3
|
+
-- to a table that no longer exists.
|
|
4
|
+
--
|
|
5
|
+
-- plugin-agenda's financial bridge computes a professional's commission like
|
|
6
|
+
-- this:
|
|
7
|
+
--
|
|
8
|
+
-- .from('v_staff').select('commission_rate').eq('id', order.assignee_id)
|
|
9
|
+
--
|
|
10
|
+
-- and its own comment says "per-professional rate lives on staff_members,
|
|
11
|
+
-- surfaced via v_staff". Neither half is true. `v_staff` is not declared by any
|
|
12
|
+
-- file in this chain — it survives on two pools as a leftover and is simply
|
|
13
|
+
-- absent on the other two — and where it DOES survive, its definition has no
|
|
14
|
+
-- `commission_rate` column. Nor does `staff_members`. Nor does anything else in
|
|
15
|
+
-- any of the four pools.
|
|
16
|
+
--
|
|
17
|
+
-- So the feature has been dead across the whole fleet, and quietly: PostgREST
|
|
18
|
+
-- answers with an error, the bridge reads `staff?.commission_rate` off a null,
|
|
19
|
+
-- `Number(undefined) || 0` gives 0, the amount is 0, and it returns null as if
|
|
20
|
+
-- the professional simply had no rate configured. Nobody sees a failure; the
|
|
21
|
+
-- commissions just never accrue. Found by asking each pool whether the objects
|
|
22
|
+
-- the code calls are actually there (scripts/check-pool-objects.mjs) — the
|
|
23
|
+
-- check that exists because reading the repo cannot answer that question.
|
|
24
|
+
--
|
|
25
|
+
-- WHERE IT GOES. `person_roles` is where a Person's professional hat lives, and
|
|
26
|
+
-- a commission rate is an attribute OF THAT HAT, not of the human: the same
|
|
27
|
+
-- person can be a professional in one unit at one rate and an employee in
|
|
28
|
+
-- another. A row per (person, role, unit) is already exactly that shape.
|
|
29
|
+
--
|
|
30
|
+
-- A PROMOTED COLUMN, NOT metadata. The same reasoning ADR 0009 used for
|
|
31
|
+
-- service_kind: money logic reasons about this value, so it gets a type, a
|
|
32
|
+
-- CHECK and a name — not a JSONB key that any writer can misspell into silence.
|
|
33
|
+
--
|
|
34
|
+
-- NULL means "no rate configured", which is what every professional in every
|
|
35
|
+
-- pool has today. Behaviour is therefore unchanged until someone sets one; what
|
|
36
|
+
-- changes is that setting one now works.
|
|
37
|
+
-- ============================================================================
|
|
38
|
+
|
|
39
|
+
ALTER TABLE public.person_roles
|
|
40
|
+
ADD COLUMN IF NOT EXISTS commission_rate numeric(7,4);
|
|
41
|
+
|
|
42
|
+
-- A rate, not an amount: 0.15 is fifteen percent. Bounded because a commission
|
|
43
|
+
-- above the sale is not a configuration anyone means, and below zero is a
|
|
44
|
+
-- deduction wearing the wrong name.
|
|
45
|
+
ALTER TABLE public.person_roles DROP CONSTRAINT IF EXISTS person_roles_commission_rate_check;
|
|
46
|
+
ALTER TABLE public.person_roles
|
|
47
|
+
ADD CONSTRAINT person_roles_commission_rate_check
|
|
48
|
+
CHECK (commission_rate IS NULL OR (commission_rate >= 0 AND commission_rate <= 1));
|
|
49
|
+
|
|
50
|
+
COMMENT ON COLUMN public.person_roles.commission_rate IS
|
|
51
|
+
'Commission this person earns wearing THIS role in THIS unit, as a rate (0.15 = 15%). NULL = none configured. Promoted column, not metadata (ADR 0009): the financial bridge computes money from it.';
|
|
52
|
+
|
|
53
|
+
-- The read the bridge makes, named once so it does not re-derive "who is the
|
|
54
|
+
-- professional on this order" in TypeScript. One row per person: a professional
|
|
55
|
+
-- hat in a specific unit wins over a tenant-wide one, because the more specific
|
|
56
|
+
-- assignment is the one someone actually decided.
|
|
57
|
+
CREATE OR REPLACE VIEW public.v_professional_rates
|
|
58
|
+
WITH (security_invoker = true) AS
|
|
59
|
+
SELECT DISTINCT ON (r.person_id)
|
|
60
|
+
r.person_id AS id,
|
|
61
|
+
r.tenant_id,
|
|
62
|
+
r.unit_id,
|
|
63
|
+
r.role,
|
|
64
|
+
p.name,
|
|
65
|
+
r.commission_rate
|
|
66
|
+
FROM public.person_roles r
|
|
67
|
+
JOIN public.people p ON p.id = r.person_id
|
|
68
|
+
WHERE r.role IN ('professional', 'employee')
|
|
69
|
+
AND (r.valid_to IS NULL OR r.valid_to > now())
|
|
70
|
+
ORDER BY r.person_id, (r.unit_id IS NULL), r.is_primary DESC, r.created_at;
|
|
71
|
+
|
|
72
|
+
GRANT SELECT ON public.v_professional_rates TO authenticated;
|
|
73
|
+
|
|
74
|
+
COMMENT ON VIEW public.v_professional_rates IS
|
|
75
|
+
'A professional and the commission rate of the role they wear (143). Replaces the undeclared v_staff the agenda bridge used to ask.';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 144_a_product_can_be_sold_in_sizes.sql — a linha do pedido aprende a variante.
|
|
3
|
+
--
|
|
4
|
+
-- O ARQUÉTIPO JÁ EXISTE, e isso é a descoberta que este arquivo registra.
|
|
5
|
+
-- `public.product_variants` foi criada pela 086 na Fase 0 — com sku, preço
|
|
6
|
+
-- absoluto ou delta, `attributes` jsonb, o scaffold de authz e a regra de
|
|
7
|
+
-- liberação por unidade. Está completa, está correta, e está VAZIA nas quatro
|
|
8
|
+
-- pools, enquanto a loja carrega 121 variantes numa tabela paralela sua.
|
|
9
|
+
--
|
|
10
|
+
-- Então este arquivo não cria nada de catálogo. Ele acrescenta as duas únicas
|
|
11
|
+
-- coisas que faltavam para o arquétipo ser usável por quem precisa dele:
|
|
12
|
+
--
|
|
13
|
+
-- §1 is_default — a variante que responde quando ninguém escolheu
|
|
14
|
+
-- §2 order_items.variant_id — a linha do pedido dizendo qual foi vendida
|
|
15
|
+
--
|
|
16
|
+
-- A SEGUNDA É O MOTIVO DE TUDO. Sem ela, uma FK de public.order_items para
|
|
17
|
+
-- public.plg_shop_product_variants faria a BASE depender de um plugin
|
|
18
|
+
-- instalável, atravessando o prefixo que o ADR 0015 define como fronteira. Na
|
|
19
|
+
-- direção plugin → base isso é permitido e comum; nesta, não — um cluster sem a
|
|
20
|
+
-- loja instalada ficaria com uma coluna apontando para o nada.
|
|
21
|
+
--
|
|
22
|
+
-- E é o que destrava o estoque: enquanto o saldo não puder ser contado por
|
|
23
|
+
-- variante, duas camisetas de tamanhos diferentes são o mesmo saldo, e a loja
|
|
24
|
+
-- continua precisando do livro dela.
|
|
25
|
+
--
|
|
26
|
+
-- O backfill das 121 linhas NÃO está aqui: a spine roda antes dos plugins e
|
|
27
|
+
-- plg_shop_product_variants ainda não existe neste ponto da cadeia. Ele vive em
|
|
28
|
+
-- packages/shop/migrations/0075, que é o lado que pode conhecer os dois.
|
|
29
|
+
-- ============================================================================
|
|
30
|
+
|
|
31
|
+
-- §1 ── a variante padrão ────────────────────────────────────────────────────
|
|
32
|
+
-- Produto é variante com uma só, e é esta coluna que faz a frase valer no dado
|
|
33
|
+
-- em vez de só no discurso: um produto sem variação real ainda tem uma linha em
|
|
34
|
+
-- product_variants, marcada aqui, e todo leitor que pergunta "qual variante?"
|
|
35
|
+
-- recebe resposta sem precisar saber se aquele produto varia.
|
|
36
|
+
ALTER TABLE public.product_variants
|
|
37
|
+
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
|
38
|
+
|
|
39
|
+
COMMENT ON COLUMN public.product_variants.is_default IS
|
|
40
|
+
'A variante que responde quando ninguém escolheu (144). Um produto sem variação real tem uma linha, e é esta.';
|
|
41
|
+
|
|
42
|
+
-- Uma padrão por produto. Parcial, porque a regra é sobre as que SÃO padrão.
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS product_variants_one_default_uidx
|
|
44
|
+
ON public.product_variants (tenant_id, product_id) WHERE is_default;
|
|
45
|
+
|
|
46
|
+
-- §2 ── a linha do pedido aprende qual variante foi vendida ─────────────────
|
|
47
|
+
-- Nullable, e NULL é o significado que toda linha de hoje já tem: o produto
|
|
48
|
+
-- inteiro. Nenhum backfill é obrigatório — as 1.422 linhas do ecommerce seguem
|
|
49
|
+
-- válidas sem tocar em nenhuma, e um salão nunca preenche a coluna.
|
|
50
|
+
ALTER TABLE public.order_items
|
|
51
|
+
ADD COLUMN IF NOT EXISTS variant_id uuid REFERENCES public.product_variants(id) ON DELETE SET NULL;
|
|
52
|
+
|
|
53
|
+
COMMENT ON COLUMN public.order_items.variant_id IS
|
|
54
|
+
'Qual variação foi vendida (144). NULL = o produto inteiro, que é o caso de todo negócio que não vende por tamanho.';
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS order_items_variant_idx
|
|
57
|
+
ON public.order_items (variant_id) WHERE variant_id IS NOT NULL;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 145_the_app_says_which_tenant_it_is_in.sql — o arquétipo volta a ser legível
|
|
3
|
+
-- por quem tem mais de uma conta.
|
|
4
|
+
--
|
|
5
|
+
-- O DEFEITO, encontrado em produção. O tenant Artorius Steakhouse parou de
|
|
6
|
+
-- listar pedidos e clientes assim que uma tela passou a ler o arquétipo. A
|
|
7
|
+
-- causa não era a tela: é que as duas camadas resolvem tenant de jeitos
|
|
8
|
+
-- diferentes.
|
|
9
|
+
--
|
|
10
|
+
-- plg_shop_customers tenant_id IN (SELECT user_tenant_ids())
|
|
11
|
+
-- people tenant_id = app.current_tenant_id() AND …
|
|
12
|
+
--
|
|
13
|
+
-- A do plugin é por FILIAÇÃO: qualquer membro logado passa, e a consulta se
|
|
14
|
+
-- estreita sozinha com o `.eq('tenant_id', …)` que o app já manda. A do
|
|
15
|
+
-- arquétipo é por CONTEXTO, e `app.current_tenant_id()` só sabe responder de
|
|
16
|
+
-- duas formas: a claim `tenant_id` do JWT, ou — se não houver claim — o único
|
|
17
|
+
-- tenant do usuário. Se ele tem dois, ela devolve NULL, e NULL nega tudo.
|
|
18
|
+
--
|
|
19
|
+
-- NENHUM APP DA FROTA EMITE ESSA CLAIM. O token é um token comum do Supabase.
|
|
20
|
+
-- Então o arquétipo inteiro só era legível para quem pertence a exatamente uma
|
|
21
|
+
-- conta — e isso funcionou por acidente: no salão, 11 de 12 usuários têm uma
|
|
22
|
+
-- conta só. No ecommerce, três donos têm quatro e cinco, e para esses três
|
|
23
|
+
-- `public.people`, `public.orders` e `public.products` sempre responderam vazio.
|
|
24
|
+
-- O defeito é anterior a qualquer tela nova; ela só o tornou visível.
|
|
25
|
+
--
|
|
26
|
+
-- A SAÍDA JÁ EXISTE NESTE REPOSITÓRIO. `x-fayz-unit` viaja em toda requisição
|
|
27
|
+
-- desde 039 e `public.requested_unit()` a lê, sob uma regra que o cliente
|
|
28
|
+
-- enuncia melhor do que eu conseguiria:
|
|
29
|
+
--
|
|
30
|
+
-- "a header cannot be trusted to narrow, and it must never be able to
|
|
31
|
+
-- widen: the policies resolve unit access through user_unit_ids(), never
|
|
32
|
+
-- through what was sent here"
|
|
33
|
+
--
|
|
34
|
+
-- `x-fayz-tenant` entra pela mesma porta e sob a mesma regra. O cabeçalho não
|
|
35
|
+
-- concede nada: ele ESCOLHE entre as contas que o usuário já tem, e a escolha é
|
|
36
|
+
-- validada contra app.memberships exatamente como a claim do JWT já era. Um
|
|
37
|
+
-- cabeçalho forjado só alcança o que `user_tenant_ids()` — a política que as
|
|
38
|
+
-- tabelas de plugin usam hoje — já alcançava. Ele estreita; nunca alarga.
|
|
39
|
+
--
|
|
40
|
+
-- A ORDEM IMPORTA e é a de confiança: claim do JWT primeiro (é assinada), depois
|
|
41
|
+
-- o cabeçalho, depois o recuo de conta única. Quem já emite a claim não muda de
|
|
42
|
+
-- comportamento.
|
|
43
|
+
-- ============================================================================
|
|
44
|
+
|
|
45
|
+
CREATE OR REPLACE FUNCTION app.requested_tenant()
|
|
46
|
+
RETURNS uuid
|
|
47
|
+
LANGUAGE plpgsql STABLE
|
|
48
|
+
SET search_path = ''
|
|
49
|
+
AS $$
|
|
50
|
+
DECLARE v text;
|
|
51
|
+
BEGIN
|
|
52
|
+
v := nullif(current_setting('request.headers', true), '')::json ->> 'x-fayz-tenant';
|
|
53
|
+
RETURN nullif(v, '')::uuid;
|
|
54
|
+
-- Engole a falha de parse pela mesma razão que requested_unit(): uma função
|
|
55
|
+
-- vizinha da RLS não é lugar de levantar exceção.
|
|
56
|
+
EXCEPTION WHEN OTHERS THEN
|
|
57
|
+
RETURN NULL;
|
|
58
|
+
END $$;
|
|
59
|
+
|
|
60
|
+
REVOKE ALL ON FUNCTION app.requested_tenant() FROM public;
|
|
61
|
+
GRANT EXECUTE ON FUNCTION app.requested_tenant() TO authenticated, service_role;
|
|
62
|
+
|
|
63
|
+
CREATE OR REPLACE FUNCTION app.current_tenant_id()
|
|
64
|
+
RETURNS uuid
|
|
65
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
66
|
+
SET search_path = ''
|
|
67
|
+
AS $$
|
|
68
|
+
DECLARE
|
|
69
|
+
v_uid uuid := auth.uid();
|
|
70
|
+
v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
|
|
71
|
+
v_raw text;
|
|
72
|
+
v_tenant uuid;
|
|
73
|
+
v_n integer;
|
|
74
|
+
BEGIN
|
|
75
|
+
-- ① a claim assinada, se existir. Inalterado desde 051.
|
|
76
|
+
IF jsonb_exists(v_jwt, 'tenant_id') THEN
|
|
77
|
+
v_raw := v_jwt ->> 'tenant_id';
|
|
78
|
+
-- Presença expressa intenção de estreitar; valor ilegível é nenhum tenant.
|
|
79
|
+
IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
|
|
80
|
+
RETURN NULL;
|
|
81
|
+
END IF;
|
|
82
|
+
v_tenant := v_raw::uuid;
|
|
83
|
+
IF v_uid IS NULL THEN
|
|
84
|
+
RETURN v_tenant;
|
|
85
|
+
END IF;
|
|
86
|
+
IF EXISTS (SELECT 1 FROM app.memberships m
|
|
87
|
+
WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
|
|
88
|
+
RETURN v_tenant;
|
|
89
|
+
END IF;
|
|
90
|
+
RETURN NULL;
|
|
91
|
+
END IF;
|
|
92
|
+
|
|
93
|
+
IF v_uid IS NULL THEN
|
|
94
|
+
RETURN NULL;
|
|
95
|
+
END IF;
|
|
96
|
+
|
|
97
|
+
-- ② o cabeçalho da requisição. A MESMA validação de filiação de ①: ele
|
|
98
|
+
-- escolhe entre as contas do usuário, não concede nenhuma. Sem sessão não há
|
|
99
|
+
-- o que validar, então este braço exige v_uid — que já está garantido acima.
|
|
100
|
+
v_tenant := app.requested_tenant();
|
|
101
|
+
IF v_tenant IS NOT NULL THEN
|
|
102
|
+
IF EXISTS (SELECT 1 FROM app.memberships m
|
|
103
|
+
WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
|
|
104
|
+
RETURN v_tenant;
|
|
105
|
+
END IF;
|
|
106
|
+
-- Cabeçalho apontando para conta que não é sua: nega, não recua. Recuar
|
|
107
|
+
-- para "sua única conta" transformaria um pedido explícito e errado numa
|
|
108
|
+
-- resposta silenciosa sobre outra conta.
|
|
109
|
+
RETURN NULL;
|
|
110
|
+
END IF;
|
|
111
|
+
|
|
112
|
+
-- ③ o recuo: conta única. Quem tem duas e não disse qual continua sem tenant,
|
|
113
|
+
-- que é a resposta correta — só que agora ela é evitável.
|
|
114
|
+
SELECT count(*), (array_agg(m.tenant_id))[1] INTO v_n, v_tenant
|
|
115
|
+
FROM app.memberships m WHERE m.user_id = v_uid AND m.active;
|
|
116
|
+
IF v_n = 1 THEN
|
|
117
|
+
RETURN v_tenant;
|
|
118
|
+
END IF;
|
|
119
|
+
RETURN NULL;
|
|
120
|
+
END $$;
|
|
121
|
+
|
|
122
|
+
COMMENT ON FUNCTION app.current_tenant_id() IS
|
|
123
|
+
'Em qual conta esta requisição está agindo: claim assinada do JWT, senão o cabeçalho x-fayz-tenant, senão a única conta do usuário (145). Os três caminhos validam filiação — nenhum concede acesso, todos escolhem entre o que já é do usuário.';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 146_products_stock_has_one_writer.sql — quem manda no estoque do produto.
|
|
3
|
+
--
|
|
4
|
+
-- O último item da Fase 0 com forma de loja. A #143 registrou assim:
|
|
5
|
+
-- "products.stock has two independent writers (inventory positions vs shop
|
|
6
|
+
-- inventory_count) with no arbitration". Dois gatilhos escrevem a mesma coluna:
|
|
7
|
+
--
|
|
8
|
+
-- plugin-inventory 014 UPDATE products SET stock = Σ posições
|
|
9
|
+
-- shop 0009 UPDATE products SET stock = NEW.inventory_count
|
|
10
|
+
--
|
|
11
|
+
-- Sem árbitro, quem dispara por último ganha. E não é hipótese: medido em
|
|
12
|
+
-- 25/08, restaurant tem 20 unidades de diferença entre os dois números e salon
|
|
13
|
+
-- tem 29. No ecommerce, products.stock vale 1.200.449 (o número da loja) contra
|
|
14
|
+
-- 12 em posições — se o espelho do inventory disparasse para aqueles produtos,
|
|
15
|
+
-- o estoque da loja colapsaria de um milhão para uma dúzia.
|
|
16
|
+
--
|
|
17
|
+
-- POR QUE UM REGISTRO E NÃO UMA REGRA INFERIDA. A tentação é decidir na hora:
|
|
18
|
+
-- "o inventory ganha se tiver movimento". Só o Artorius Steakhouse tem os dois
|
|
19
|
+
-- livros — 2 movimentos do inventory contra 24 da loja — e essa regra o
|
|
20
|
+
-- entregaria ao inventory, colapsando exatamente o estoque que o lojista vê.
|
|
21
|
+
-- Uma coluna que muda de dono conforme o dado do dia não tem dono.
|
|
22
|
+
--
|
|
23
|
+
-- O PADRÃO É O CONTRATO DECLARADO. O 012 do inventory já diz que products.stock
|
|
24
|
+
-- "become one-way MIRRORS maintained by 007", então a ausência de registro
|
|
25
|
+
-- significa inventory. A loja registra a EXCEÇÃO onde ela é, de fato, quem
|
|
26
|
+
-- mantém o número — e registra por linha, não por DDL, que é a mesma forma que
|
|
27
|
+
-- 080 usou para as espécies de venda.
|
|
28
|
+
--
|
|
29
|
+
-- Este arquivo não conhece nenhum plugin: declara a mesa e a pergunta. Quem
|
|
30
|
+
-- responde "sou eu" é cada plugin, no arquivo dele.
|
|
31
|
+
-- ============================================================================
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS app.product_stock_source (
|
|
34
|
+
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
35
|
+
source text NOT NULL CHECK (source IN ('inventory', 'shop')),
|
|
36
|
+
note text,
|
|
37
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
38
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
COMMENT ON TABLE app.product_stock_source IS
|
|
42
|
+
'Quem mantém public.products.stock nesta conta (146). Linha ausente = inventory, que é o contrato declarado pelo 012. Um registro e não uma inferência: uma coluna que muda de dono conforme o dado do dia não tem dono.';
|
|
43
|
+
|
|
44
|
+
ALTER TABLE app.product_stock_source ENABLE ROW LEVEL SECURITY;
|
|
45
|
+
DROP POLICY IF EXISTS product_stock_source_read ON app.product_stock_source;
|
|
46
|
+
CREATE POLICY product_stock_source_read ON app.product_stock_source
|
|
47
|
+
FOR SELECT TO authenticated USING (true);
|
|
48
|
+
GRANT SELECT ON app.product_stock_source TO authenticated;
|
|
49
|
+
|
|
50
|
+
CREATE OR REPLACE FUNCTION app.product_stock_owner(p_tenant uuid)
|
|
51
|
+
RETURNS text
|
|
52
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
53
|
+
SET search_path = ''
|
|
54
|
+
AS $$
|
|
55
|
+
SELECT coalesce(
|
|
56
|
+
(SELECT s.source FROM app.product_stock_source s WHERE s.tenant_id = p_tenant),
|
|
57
|
+
'inventory')
|
|
58
|
+
$$;
|
|
59
|
+
|
|
60
|
+
COMMENT ON FUNCTION app.product_stock_owner(uuid) IS
|
|
61
|
+
'Qual livro mantém products.stock nesta conta: inventory (padrão, contrato do 012) ou shop. Todo escritor daquela coluna pergunta antes de escrever — é o que a #143 chamava de arbitragem ausente.';
|
|
62
|
+
|
|
63
|
+
REVOKE ALL ON FUNCTION app.product_stock_owner(uuid) FROM public;
|
|
64
|
+
GRANT EXECUTE ON FUNCTION app.product_stock_owner(uuid) TO authenticated, service_role;
|
|
65
|
+
|
|
66
|
+
COMMENT ON COLUMN public.products.stock IS
|
|
67
|
+
'deprecated como fonte: o saldo real vive no livro de estoque. Mantido como espelho de UM escritor só, escolhido por app.product_stock_owner(tenant) (146). Nunca escrever sem perguntar — dois escritores nesta coluna foi a #143.';
|