@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,144 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 151_stock_balance_is_an_archetype.sql — how much there is, in one place
|
|
3
|
-
-- (#276, ADR 0024).
|
|
4
|
-
--
|
|
5
|
-
-- Two books have been kept for the same number. `plg_inventory_stock_positions`
|
|
6
|
-
-- counts by (product, location, batch, expiry) in numeric; `plg_shop_stock_
|
|
7
|
-
-- balances` counts by (variant, location, lot) in integers, with its own
|
|
8
|
-
-- reserved/available. 146 measured the consequence and wrote it down: restaurant
|
|
9
|
-
-- disagreed with itself by 20 units, salon by 29, and the ecommerce pool held
|
|
10
|
-
-- 1,200,449 on one book against 12 on the other. Its answer was a register
|
|
11
|
-
-- saying which book wins per tenant — the right answer for that day, and not an
|
|
12
|
-
-- answer to the question, which is that a number with two writers has no owner.
|
|
13
|
-
--
|
|
14
|
-
-- THE GRAIN, and why it is not the finer one. The archetype counts by
|
|
15
|
-
-- (variant × UNIT), not by (variant × shelf). A unit is a base concept every
|
|
16
|
-
-- plugin already shares — it is where the tenant operates. A shelf, a lot, a
|
|
17
|
-
-- batch and an expiry date are how a particular plugin organises what sits in
|
|
18
|
-
-- that unit, and they stay with that plugin, summing into the archetype. That
|
|
19
|
-
-- is the same shape the whole model uses: the base owns the form every plugin
|
|
20
|
-
-- needs, the plugin owns its own detail.
|
|
21
|
-
--
|
|
22
|
-
-- THE WRITER. One: `public.stock_apply`. RLS denies direct writes to the table
|
|
23
|
-
-- outright, so "who moved the stock" has exactly one answer and the movement
|
|
24
|
-
-- that caused it is recorded on the row. plugin-inventory calls it from the
|
|
25
|
-
-- trigger it already has on its movements; the shop's own decrement keeps
|
|
26
|
-
-- running for now and its cutover is #276's remaining half, gated on the
|
|
27
|
-
-- reconciliation view below reading zero.
|
|
28
|
-
--
|
|
29
|
-
-- WHAT THIS FILE DELIBERATELY DOES NOT DO. It does not revoke the shop's writes
|
|
30
|
-
-- to its own book. Doing that in the same migration that introduces the
|
|
31
|
-
-- archetype would be a cutover with no way back on a pool holding a million
|
|
32
|
-
-- units — and 148 already set the precedent for the honest order: introduce,
|
|
33
|
-
-- reconcile, then retire with the count in hand.
|
|
34
|
-
--
|
|
35
|
-
-- Idempotent and replay-safe.
|
|
36
|
-
-- ============================================================================
|
|
37
|
-
|
|
38
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
39
|
-
-- §1 The balance
|
|
40
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
CREATE TABLE IF NOT EXISTS public.stock_balances (
|
|
43
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
44
|
-
variant_id uuid NOT NULL REFERENCES public.product_variants(id) ON DELETE CASCADE,
|
|
45
|
-
unit_id uuid,
|
|
46
|
-
on_hand numeric(14,4) NOT NULL DEFAULT 0,
|
|
47
|
-
reserved numeric(14,4) NOT NULL DEFAULT 0 CHECK (reserved >= 0),
|
|
48
|
-
available numeric(14,4) GENERATED ALWAYS AS (on_hand - reserved) STORED,
|
|
49
|
-
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
50
|
-
updated_by text,
|
|
51
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
COMMENT ON TABLE public.stock_balances IS
|
|
55
|
-
'How much of a variant a unit has (151, ADR 0024). The archetype: one number, one writer (public.stock_apply). Batches, lots, shelves and expiry dates are plugin detail that sums into this — the base owns the form, the plugin owns its own grain.';
|
|
56
|
-
COMMENT ON COLUMN public.stock_balances.unit_id IS
|
|
57
|
-
'The operating unit holding it. NULL is the tenant-wide balance, for a tenant that does not separate stock by unit.';
|
|
58
|
-
COMMENT ON COLUMN public.stock_balances.updated_by IS
|
|
59
|
-
'Which book last moved this number. With one writer this is a breadcrumb, not arbitration — 146 needed arbitration because there were two.';
|
|
60
|
-
|
|
61
|
-
-- A tenant-wide balance is a real thing (a tenant that does not separate stock
|
|
62
|
-
-- by unit), and NULL is how it is spelled — which a composite PRIMARY KEY cannot
|
|
63
|
-
-- hold, because every PK column is NOT NULL. The identity is therefore an
|
|
64
|
-
-- expression index, and `stock_apply` upserts on exactly that expression: one
|
|
65
|
-
-- row per (tenant, variant, unit-or-tenant-wide), NULL included.
|
|
66
|
-
CREATE UNIQUE INDEX IF NOT EXISTS stock_balances_identity
|
|
67
|
-
ON public.stock_balances (tenant_id, variant_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid));
|
|
68
|
-
|
|
69
|
-
ALTER TABLE public.stock_balances ENABLE ROW LEVEL SECURITY;
|
|
70
|
-
|
|
71
|
-
-- 002's generic sweep gives every public table carrying tenant_id the four
|
|
72
|
-
-- `tenant_isolation_*` policies, and on a REPLAY it meets this table and hands
|
|
73
|
-
-- it an INSERT, an UPDATE and a DELETE — which would make "one writer" a
|
|
74
|
-
-- comment rather than a rule. It runs before this file on every pass, so
|
|
75
|
-
-- dropping them here is what holds, and holds again on the next replay.
|
|
76
|
-
DROP POLICY IF EXISTS tenant_isolation_select ON public.stock_balances;
|
|
77
|
-
DROP POLICY IF EXISTS tenant_isolation_insert ON public.stock_balances;
|
|
78
|
-
DROP POLICY IF EXISTS tenant_isolation_update ON public.stock_balances;
|
|
79
|
-
DROP POLICY IF EXISTS tenant_isolation_delete ON public.stock_balances;
|
|
80
|
-
|
|
81
|
-
-- Read for the tenant; NOBODY writes directly. The absence of an INSERT/UPDATE
|
|
82
|
-
-- policy is the point: `stock_apply` is SECURITY DEFINER and is the only path.
|
|
83
|
-
DROP POLICY IF EXISTS stock_balances_read ON public.stock_balances;
|
|
84
|
-
CREATE POLICY stock_balances_read ON public.stock_balances
|
|
85
|
-
FOR SELECT TO authenticated
|
|
86
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
87
|
-
GRANT SELECT ON public.stock_balances TO authenticated;
|
|
88
|
-
GRANT ALL ON public.stock_balances TO service_role;
|
|
89
|
-
REVOKE INSERT, UPDATE, DELETE ON public.stock_balances FROM anon, authenticated;
|
|
90
|
-
|
|
91
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
92
|
-
-- §2 The only writer
|
|
93
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
CREATE OR REPLACE FUNCTION public.stock_apply(
|
|
96
|
-
p_tenant uuid,
|
|
97
|
-
p_variant uuid,
|
|
98
|
-
p_unit uuid,
|
|
99
|
-
p_on_hand numeric DEFAULT 0,
|
|
100
|
-
p_reserved numeric DEFAULT 0,
|
|
101
|
-
p_source text DEFAULT 'inventory'
|
|
102
|
-
) RETURNS jsonb
|
|
103
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
104
|
-
AS $$
|
|
105
|
-
DECLARE
|
|
106
|
-
v_on_hand numeric;
|
|
107
|
-
v_reserved numeric;
|
|
108
|
-
BEGIN
|
|
109
|
-
IF p_tenant IS NULL OR p_variant IS NULL THEN
|
|
110
|
-
RAISE EXCEPTION 'stock_apply: tenant and variant are required' USING ERRCODE = '22023';
|
|
111
|
-
END IF;
|
|
112
|
-
|
|
113
|
-
INSERT INTO public.stock_balances (tenant_id, variant_id, unit_id, on_hand, reserved, updated_by)
|
|
114
|
-
VALUES (p_tenant, p_variant, p_unit, coalesce(p_on_hand, 0), greatest(coalesce(p_reserved, 0), 0), p_source)
|
|
115
|
-
ON CONFLICT (tenant_id, variant_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO UPDATE
|
|
116
|
-
SET on_hand = public.stock_balances.on_hand + coalesce(p_on_hand, 0),
|
|
117
|
-
reserved = greatest(public.stock_balances.reserved + coalesce(p_reserved, 0), 0),
|
|
118
|
-
updated_at = now(),
|
|
119
|
-
updated_by = p_source
|
|
120
|
-
RETURNING on_hand, reserved INTO v_on_hand, v_reserved;
|
|
121
|
-
|
|
122
|
-
-- Below zero is a data question, not a rounding one: the caller that asked for
|
|
123
|
-
-- it is the one that has to answer.
|
|
124
|
-
IF v_on_hand < 0 THEN
|
|
125
|
-
RAISE EXCEPTION 'stock_apply: % would take variant % below zero (%)', p_source, p_variant, v_on_hand
|
|
126
|
-
USING ERRCODE = '23514';
|
|
127
|
-
END IF;
|
|
128
|
-
|
|
129
|
-
RETURN jsonb_build_object('on_hand', v_on_hand, 'reserved', v_reserved, 'available', v_on_hand - v_reserved);
|
|
130
|
-
END $$;
|
|
131
|
-
|
|
132
|
-
COMMENT ON FUNCTION public.stock_apply(uuid, uuid, uuid, numeric, numeric, text) IS
|
|
133
|
-
'The only writer of public.stock_balances (151). Applies a DELTA — a movement, not a number — which is what makes two concurrent sales add up instead of overwrite each other. Below zero raises: it is a data question, and the caller is who answers it.';
|
|
134
|
-
|
|
135
|
-
REVOKE ALL ON FUNCTION public.stock_apply(uuid, uuid, uuid, numeric, numeric, text) FROM public, anon, authenticated;
|
|
136
|
-
GRANT EXECUTE ON FUNCTION public.stock_apply(uuid, uuid, uuid, numeric, numeric, text) TO service_role;
|
|
137
|
-
|
|
138
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
139
|
-
-- §3 Reconciliation lives with the book it compares against
|
|
140
|
-
--
|
|
141
|
-
-- "Where does the archetype disagree with the shop's book?" is half a question
|
|
142
|
-
-- about a plugin's table, so the base cannot ask it: a pool without the shop has
|
|
143
|
-
-- no second book and no reason to carry the view. It is packages/shop 0082.
|
|
144
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 152_a_file_is_an_archetype.sql — the register is the base's, the bytes are
|
|
3
|
-
-- somebody else's (#282, ADR 0020).
|
|
4
|
-
--
|
|
5
|
-
-- An attachment shows up in every domain: the photo on a record, the receipt on
|
|
6
|
-
-- an invoice, the file on a form answer, the picture of a product, the document
|
|
7
|
-
-- of an employee. It had no owner. `public.documents` exists and is very nearly
|
|
8
|
-
-- the right shape — but plugin-forms creates it, which means a base-looking name
|
|
9
|
-
-- belongs to an optional plugin, and a pool without forms has no attachments at
|
|
10
|
-
-- all.
|
|
11
|
-
--
|
|
12
|
-
-- THE SPLIT, which is the decision: the base owns the REGISTER — who the file
|
|
13
|
-
-- belongs to, what species it is, who may see it — and the BYTES live in
|
|
14
|
-
-- external storage (R2, Supabase Storage), with the row pointing at them. A
|
|
15
|
-
-- database is a bad filesystem, and a filesystem is a terrible permission
|
|
16
|
-
-- system; each keeps what it is good at.
|
|
17
|
-
--
|
|
18
|
-
-- WHAT THIS BUYS, and it is the reason the decision names it: "everything you
|
|
19
|
-
-- have about this person" becomes ONE query. With attachments spread across
|
|
20
|
-
-- plugin tables, an LGPD erasure request is a sweep, and a plugin nobody
|
|
21
|
-
-- remembered is a fine. And "who may see this file" becomes ONE rule instead of
|
|
22
|
-
-- N — which is also why it has to be right, because it is now a single point of
|
|
23
|
-
-- failure for every attachment in the system.
|
|
24
|
-
--
|
|
25
|
-
-- Ownership is transferred, not recreated: the table already exists in pools
|
|
26
|
-
-- with real rows, so this file adopts it — declares the base as its owner,
|
|
27
|
-
-- adds what was missing, and leaves the data alone. plugin-forms' own CREATE
|
|
28
|
-
-- stays as the guarded no-op it already is.
|
|
29
|
-
--
|
|
30
|
-
-- Idempotent and replay-safe.
|
|
31
|
-
-- ============================================================================
|
|
32
|
-
|
|
33
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
34
|
-
-- §1 The archetype, adopted
|
|
35
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
CREATE TABLE IF NOT EXISTS public.documents (
|
|
38
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
39
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
40
|
-
kind text NOT NULL DEFAULT 'attachment',
|
|
41
|
-
person_id uuid REFERENCES public.people(id) ON DELETE SET NULL,
|
|
42
|
-
title text,
|
|
43
|
-
description text,
|
|
44
|
-
status text NOT NULL DEFAULT 'draft',
|
|
45
|
-
file_url text,
|
|
46
|
-
file_name text,
|
|
47
|
-
file_size integer,
|
|
48
|
-
mime_type text,
|
|
49
|
-
tags text[] DEFAULT '{}',
|
|
50
|
-
notes text,
|
|
51
|
-
is_active boolean NOT NULL DEFAULT true,
|
|
52
|
-
metadata jsonb DEFAULT '{}',
|
|
53
|
-
created_by uuid,
|
|
54
|
-
updated_by uuid,
|
|
55
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
56
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
-- What the bytes are, and where. `file_url` predates this and stays for the
|
|
60
|
-
-- readers that use it; the three columns below are what a storage service needs
|
|
61
|
-
-- to find, sign and delete an object without parsing a URL.
|
|
62
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_provider text;
|
|
63
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_bucket text;
|
|
64
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_path text;
|
|
65
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS checksum text;
|
|
66
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS unit_id uuid;
|
|
67
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS subject_type text;
|
|
68
|
-
ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS subject_id uuid;
|
|
69
|
-
|
|
70
|
-
COMMENT ON TABLE public.documents IS
|
|
71
|
-
'The Arquivo archetype (152, ADR 0020): the REGISTER of a file — who it belongs to, what species it is, who may see it. The bytes live in external storage and this row points at them. Owned by the base since 152; plugin-forms'' CREATE is a guarded no-op.';
|
|
72
|
-
COMMENT ON COLUMN public.documents.storage_provider IS
|
|
73
|
-
'Where the bytes are: r2, supabase, or another service. NULL on a row that predates the split and still answers through file_url.';
|
|
74
|
-
COMMENT ON COLUMN public.documents.storage_path IS
|
|
75
|
-
'The object key inside the bucket. With provider and bucket, this is everything a storage service needs to sign, fetch or delete it — no URL parsing.';
|
|
76
|
-
COMMENT ON COLUMN public.documents.subject_type IS
|
|
77
|
-
'What the file is ABOUT when it is not about a person: an invoice, an order, a product. person_id stays a first-class column because the LGPD question is asked about people.';
|
|
78
|
-
|
|
79
|
-
-- N14: a public base table carrying tenant_id has RLS on and forced, no
|
|
80
|
-
-- exceptions. The scaffold in §3 adds the template policies; it does not turn
|
|
81
|
-
-- RLS on for a table it is meeting for the first time, and a pool without
|
|
82
|
-
-- plugin-forms meets this table here.
|
|
83
|
-
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
|
|
84
|
-
ALTER TABLE public.documents FORCE ROW LEVEL SECURITY;
|
|
85
|
-
|
|
86
|
-
CREATE INDEX IF NOT EXISTS documents_person ON public.documents (tenant_id, person_id) WHERE person_id IS NOT NULL;
|
|
87
|
-
CREATE INDEX IF NOT EXISTS documents_subject ON public.documents (tenant_id, subject_type, subject_id) WHERE subject_id IS NOT NULL;
|
|
88
|
-
|
|
89
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
90
|
-
-- §2 Its species registry, in the shape 147 gave every archetype
|
|
91
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
CREATE TABLE IF NOT EXISTS public.document_kinds (
|
|
94
|
-
kind text PRIMARY KEY,
|
|
95
|
-
plugin text NOT NULL,
|
|
96
|
-
description text,
|
|
97
|
-
statuses text[],
|
|
98
|
-
transitions jsonb,
|
|
99
|
-
status text NOT NULL DEFAULT 'declared',
|
|
100
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
101
|
-
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
102
|
-
CONSTRAINT document_kinds_format CHECK (kind ~ '^[a-z][a-z0-9_]*$'),
|
|
103
|
-
CONSTRAINT document_kinds_status CHECK (status IN ('declared', 'adopted'))
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
COMMENT ON TABLE public.document_kinds IS
|
|
107
|
-
'The species registry of the Arquivo archetype (152): invoice attachment, record photo, form file — each declared by the plugin that owns it.';
|
|
108
|
-
|
|
109
|
-
ALTER TABLE public.document_kinds ENABLE ROW LEVEL SECURITY;
|
|
110
|
-
DROP POLICY IF EXISTS document_kinds_select ON public.document_kinds;
|
|
111
|
-
CREATE POLICY document_kinds_select ON public.document_kinds
|
|
112
|
-
FOR SELECT TO anon, authenticated, service_role USING (true);
|
|
113
|
-
GRANT SELECT ON public.document_kinds TO anon, authenticated, service_role;
|
|
114
|
-
GRANT ALL ON public.document_kinds TO service_role;
|
|
115
|
-
REVOKE INSERT, UPDATE, DELETE ON public.document_kinds FROM anon, authenticated;
|
|
116
|
-
|
|
117
|
-
DROP TRIGGER IF EXISTS document_kinds_updated_at ON public.document_kinds;
|
|
118
|
-
CREATE TRIGGER document_kinds_updated_at BEFORE UPDATE ON public.document_kinds
|
|
119
|
-
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
120
|
-
|
|
121
|
-
-- 147's dispatcher learns the archetype that did not exist when it was written.
|
|
122
|
-
CREATE OR REPLACE FUNCTION public.kind_registry_table(p_archetype text)
|
|
123
|
-
RETURNS text
|
|
124
|
-
LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
|
125
|
-
AS $$
|
|
126
|
-
SELECT CASE p_archetype
|
|
127
|
-
WHEN 'order' THEN 'order_kinds'
|
|
128
|
-
WHEN 'person_role' THEN 'person_role_kinds'
|
|
129
|
-
WHEN 'person' THEN 'person_kinds'
|
|
130
|
-
WHEN 'category' THEN 'category_kinds'
|
|
131
|
-
WHEN 'appointment' THEN 'appointment_kinds'
|
|
132
|
-
WHEN 'schedule' THEN 'schedule_kinds'
|
|
133
|
-
WHEN 'document' THEN 'document_kinds'
|
|
134
|
-
END
|
|
135
|
-
$$;
|
|
136
|
-
|
|
137
|
-
-- What the base itself files. A plugin's own species is declared in that
|
|
138
|
-
-- plugin's migration.
|
|
139
|
-
SELECT public.register_kind('document', d.kind, 'base', NULL, NULL, d.note)
|
|
140
|
-
FROM (VALUES
|
|
141
|
-
('attachment', 'A file attached to something, with no more specific meaning'),
|
|
142
|
-
('image', 'A picture'),
|
|
143
|
-
('contract', 'A document with a signature and a consequence')
|
|
144
|
-
) AS d(kind, note);
|
|
145
|
-
|
|
146
|
-
SELECT public.adopt_existing_kinds('document', 'documents', 'kind');
|
|
147
|
-
|
|
148
|
-
DO $$
|
|
149
|
-
BEGIN
|
|
150
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'documents_kind_registered') THEN
|
|
151
|
-
ALTER TABLE public.documents
|
|
152
|
-
ADD CONSTRAINT documents_kind_registered FOREIGN KEY (kind) REFERENCES public.document_kinds(kind);
|
|
153
|
-
END IF;
|
|
154
|
-
END $$;
|
|
155
|
-
|
|
156
|
-
-- The census speaks for seven archetypes now.
|
|
157
|
-
CREATE OR REPLACE VIEW public.v_archetype_kinds
|
|
158
|
-
WITH (security_invoker = true) AS
|
|
159
|
-
SELECT 'order'::text AS archetype, kind, plugin, description, statuses, transitions, status FROM public.order_kinds
|
|
160
|
-
UNION ALL SELECT 'person_role', kind, plugin, description, statuses, transitions, status FROM public.person_role_kinds
|
|
161
|
-
UNION ALL SELECT 'person', kind, plugin, description, statuses, transitions, status FROM public.person_kinds
|
|
162
|
-
UNION ALL SELECT 'category', kind, plugin, description, statuses, transitions, status FROM public.category_kinds
|
|
163
|
-
UNION ALL SELECT 'appointment', kind, plugin, description, statuses, transitions, status FROM public.appointment_kinds
|
|
164
|
-
UNION ALL SELECT 'schedule', kind, plugin, description, statuses, transitions, status FROM public.schedule_kinds
|
|
165
|
-
UNION ALL SELECT 'document', kind, plugin, description, statuses, transitions, status FROM public.document_kinds;
|
|
166
|
-
|
|
167
|
-
GRANT SELECT ON public.v_archetype_kinds TO anon, authenticated, service_role;
|
|
168
|
-
|
|
169
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
170
|
-
-- §3 One rule for who may see a file
|
|
171
|
-
--
|
|
172
|
-
-- The scaffold is the platform's single authorization template — the same one
|
|
173
|
-
-- every business table went through in the Fase 0 sweep. Putting documents
|
|
174
|
-
-- under it is what makes "who may see this file" one rule rather than N, and
|
|
175
|
-
-- what makes that rule the same one that governs everything else.
|
|
176
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
DO $$
|
|
179
|
-
BEGIN
|
|
180
|
-
IF to_regproc('app.scaffold_table(text,text,text,boolean,text)') IS NULL THEN
|
|
181
|
-
RETURN;
|
|
182
|
-
END IF;
|
|
183
|
-
IF NOT EXISTS (SELECT 1 FROM app.scaffold_registry WHERE table_schema = 'public' AND table_name = 'documents') THEN
|
|
184
|
-
PERFORM app.scaffold_table('public.documents', 'core.document', 'core', true, 'shadow');
|
|
185
|
-
END IF;
|
|
186
|
-
END $$;
|
|
187
|
-
|
|
188
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
189
|
-
-- §4 The LGPD question, answered in one place
|
|
190
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
CREATE OR REPLACE VIEW public.v_person_files
|
|
193
|
-
WITH (security_invoker = true) AS
|
|
194
|
-
SELECT d.id, d.tenant_id, d.person_id, d.kind, d.title, d.file_name, d.mime_type, d.file_size,
|
|
195
|
-
d.storage_provider, d.storage_bucket, d.storage_path, d.created_at
|
|
196
|
-
FROM public.documents d
|
|
197
|
-
WHERE d.person_id IS NOT NULL
|
|
198
|
-
AND d.is_active;
|
|
199
|
-
|
|
200
|
-
COMMENT ON VIEW public.v_person_files IS
|
|
201
|
-
'Every file the platform holds about a Person (152, ADR 0020). One query answers the LGPD request — which is the reason the register is in the base rather than spread across the plugins that produced the files.';
|
|
202
|
-
|
|
203
|
-
GRANT SELECT ON public.v_person_files TO authenticated, service_role;
|
|
204
|
-
|
|
205
|
-
CREATE OR REPLACE FUNCTION public.person_storage_usage(p_person uuid)
|
|
206
|
-
RETURNS jsonb
|
|
207
|
-
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
208
|
-
AS $$
|
|
209
|
-
SELECT jsonb_build_object(
|
|
210
|
-
'person_id', p_person,
|
|
211
|
-
'files', count(*),
|
|
212
|
-
'bytes', coalesce(sum(d.file_size), 0)
|
|
213
|
-
)
|
|
214
|
-
FROM public.documents d
|
|
215
|
-
WHERE d.person_id = p_person
|
|
216
|
-
AND d.tenant_id = app.current_tenant_id()
|
|
217
|
-
$$;
|
|
218
|
-
|
|
219
|
-
COMMENT ON FUNCTION public.person_storage_usage(uuid) IS
|
|
220
|
-
'How many files and how many bytes the platform holds about a Person (152). A direct sum, because there is one register.';
|
|
221
|
-
|
|
222
|
-
GRANT EXECUTE ON FUNCTION public.person_storage_usage(uuid) TO authenticated, service_role;
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 153_a_plugin_calls_a_service_through_one_door.sql — the credential is the
|
|
3
|
-
-- cluster's, the contract decides, the plugin sees neither (#278, ADR 0018).
|
|
4
|
-
--
|
|
5
|
-
-- The round-3 decision put the valuable half of an open plugin in a SERVICE: the
|
|
6
|
-
-- agenda ships open with the button, and the intelligence that suggests where to
|
|
7
|
-
-- fit an appointment is an API we host. That created a question nobody had
|
|
8
|
-
-- answered — who carries the credential, and who decides whether this tenant may
|
|
9
|
-
-- call at all.
|
|
10
|
-
--
|
|
11
|
-
-- The answer that was chosen: THE CLUSTER SPEAKS FOR THE PLUGIN. The plugin
|
|
12
|
-
-- calls a name; the call leaves from the server with the cluster's credential;
|
|
13
|
-
-- the service reads the tenant's contract and decides. Three things fall out of
|
|
14
|
-
-- that, and they are why it was chosen over the alternatives:
|
|
15
|
-
--
|
|
16
|
-
-- · the plugin never holds a secret, so an open-source plugin can call a
|
|
17
|
-
-- closed service without shipping anything worth stealing
|
|
18
|
-
-- · the customer configures nothing to use what they already bought — the
|
|
19
|
-
-- "paste your API key" flow is for someone else's API, not ours
|
|
20
|
-
-- · it works with no browser present, which the event consumer (#277) needs:
|
|
21
|
-
-- the 2am reaction has no session to borrow
|
|
22
|
-
--
|
|
23
|
-
-- WHAT LIVES HERE and what does not. This file is the REGISTRY and the
|
|
24
|
-
-- ENTITLEMENT CHECK — which services exist, what each one costs in permission
|
|
25
|
-
-- terms, whether this tenant may call it, and the record of every call. The HTTP
|
|
26
|
-
-- leg is not in the database: an edge function reads this to decide and then
|
|
27
|
-
-- makes the request. A migration that opened a socket would be the wrong kind of
|
|
28
|
-
-- clever.
|
|
29
|
-
--
|
|
30
|
-
-- DEGRADING. Every service must have a manual path, or say plainly that it has
|
|
31
|
-
-- none. `manual_fallback` is that declaration, and it is NOT NULL — a service
|
|
32
|
-
-- that cannot answer "what happens when I am down" has not been specified.
|
|
33
|
-
--
|
|
34
|
-
-- Idempotent and replay-safe.
|
|
35
|
-
-- ============================================================================
|
|
36
|
-
|
|
37
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
38
|
-
-- §1 The registry of services
|
|
39
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
CREATE TABLE IF NOT EXISTS app.services (
|
|
42
|
-
name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9_.]*$'),
|
|
43
|
-
plugin text NOT NULL,
|
|
44
|
-
description text,
|
|
45
|
-
-- What a caller must hold to reach it. NULL means any member of the tenant.
|
|
46
|
-
permission text,
|
|
47
|
-
-- The entitlement that must be on for this tenant. NULL means "included".
|
|
48
|
-
entitlement text,
|
|
49
|
-
-- What the user can still do when the service is unreachable. Required: a
|
|
50
|
-
-- service with no answer here has not been thought through.
|
|
51
|
-
manual_fallback text NOT NULL,
|
|
52
|
-
timeout_ms integer NOT NULL DEFAULT 8000 CHECK (timeout_ms BETWEEN 100 AND 60000),
|
|
53
|
-
is_active boolean NOT NULL DEFAULT true,
|
|
54
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
55
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
COMMENT ON TABLE app.services IS
|
|
59
|
-
'Which services exist and what it takes to call one (153, ADR 0018). The plugin calls a NAME; the cluster carries the credential; this table is what says whether the call is allowed at all.';
|
|
60
|
-
COMMENT ON COLUMN app.services.manual_fallback IS
|
|
61
|
-
'What the user can still do with the service unreachable. NOT NULL on purpose: "the plugin must work without it" is a rule, and a rule with no answer per service is a slogan.';
|
|
62
|
-
|
|
63
|
-
ALTER TABLE app.services ENABLE ROW LEVEL SECURITY;
|
|
64
|
-
DROP POLICY IF EXISTS services_read ON app.services;
|
|
65
|
-
CREATE POLICY services_read ON app.services FOR SELECT TO authenticated USING (true);
|
|
66
|
-
GRANT SELECT ON app.services TO authenticated;
|
|
67
|
-
GRANT ALL ON app.services TO service_role;
|
|
68
|
-
|
|
69
|
-
DROP TRIGGER IF EXISTS services_updated_at ON app.services;
|
|
70
|
-
CREATE TRIGGER services_updated_at BEFORE UPDATE ON app.services
|
|
71
|
-
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
72
|
-
|
|
73
|
-
CREATE OR REPLACE FUNCTION public.register_service(
|
|
74
|
-
p_name text,
|
|
75
|
-
p_plugin text,
|
|
76
|
-
p_manual_fallback text,
|
|
77
|
-
p_permission text DEFAULT NULL,
|
|
78
|
-
p_entitlement text DEFAULT NULL,
|
|
79
|
-
p_description text DEFAULT NULL,
|
|
80
|
-
p_timeout_ms integer DEFAULT 8000
|
|
81
|
-
) RETURNS void
|
|
82
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
83
|
-
AS $$
|
|
84
|
-
BEGIN
|
|
85
|
-
IF coalesce(btrim(p_manual_fallback), '') = '' THEN
|
|
86
|
-
RAISE EXCEPTION 'register_service: % must declare what works without it', p_name
|
|
87
|
-
USING ERRCODE = '22023',
|
|
88
|
-
HINT = 'If there is genuinely no manual path, say so — "none: the feature does not exist offline" is a valid answer and a visible one.';
|
|
89
|
-
END IF;
|
|
90
|
-
INSERT INTO app.services (name, plugin, description, permission, entitlement, manual_fallback, timeout_ms)
|
|
91
|
-
VALUES (p_name, p_plugin, p_description, p_permission, p_entitlement, p_manual_fallback, p_timeout_ms)
|
|
92
|
-
ON CONFLICT (name) DO UPDATE
|
|
93
|
-
SET plugin = EXCLUDED.plugin,
|
|
94
|
-
description = coalesce(EXCLUDED.description, app.services.description),
|
|
95
|
-
permission = EXCLUDED.permission,
|
|
96
|
-
entitlement = EXCLUDED.entitlement,
|
|
97
|
-
manual_fallback = EXCLUDED.manual_fallback,
|
|
98
|
-
timeout_ms = EXCLUDED.timeout_ms,
|
|
99
|
-
is_active = true;
|
|
100
|
-
END $$;
|
|
101
|
-
|
|
102
|
-
REVOKE ALL ON FUNCTION public.register_service(text, text, text, text, text, text, integer) FROM public, anon, authenticated;
|
|
103
|
-
GRANT EXECUTE ON FUNCTION public.register_service(text, text, text, text, text, text, integer) TO service_role;
|
|
104
|
-
|
|
105
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
106
|
-
-- §2 May this call be made?
|
|
107
|
-
--
|
|
108
|
-
-- One answer, given before the request leaves, so a refusal costs nothing and
|
|
109
|
-
-- reads the same everywhere: not the network's job, not each plugin's job.
|
|
110
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
CREATE OR REPLACE FUNCTION public.service_authorize(p_name text)
|
|
113
|
-
RETURNS jsonb
|
|
114
|
-
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
115
|
-
AS $$
|
|
116
|
-
DECLARE
|
|
117
|
-
v_service app.services%ROWTYPE;
|
|
118
|
-
v_tenant uuid := app.current_tenant_id();
|
|
119
|
-
BEGIN
|
|
120
|
-
SELECT * INTO v_service FROM app.services WHERE name = p_name;
|
|
121
|
-
|
|
122
|
-
IF NOT FOUND OR NOT v_service.is_active THEN
|
|
123
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'unknown_service');
|
|
124
|
-
END IF;
|
|
125
|
-
IF v_tenant IS NULL THEN
|
|
126
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'no_tenant');
|
|
127
|
-
END IF;
|
|
128
|
-
IF v_service.permission IS NOT NULL AND NOT app.has_permission(v_service.permission) THEN
|
|
129
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'forbidden',
|
|
130
|
-
'permission', v_service.permission,
|
|
131
|
-
'manual_fallback', v_service.manual_fallback);
|
|
132
|
-
END IF;
|
|
133
|
-
IF v_service.entitlement IS NOT NULL AND NOT public.tenant_plugin_active(v_service.plugin, v_service.entitlement) THEN
|
|
134
|
-
-- Not an error: an upsell. The caller shows the button and what it costs,
|
|
135
|
-
-- rather than a failure the user cannot act on.
|
|
136
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'not_contracted',
|
|
137
|
-
'entitlement', v_service.entitlement,
|
|
138
|
-
'plugin', v_service.plugin,
|
|
139
|
-
'manual_fallback', v_service.manual_fallback);
|
|
140
|
-
END IF;
|
|
141
|
-
|
|
142
|
-
RETURN jsonb_build_object('allowed', true, 'service', v_service.name, 'tenant_id', v_tenant,
|
|
143
|
-
'timeout_ms', v_service.timeout_ms,
|
|
144
|
-
'manual_fallback', v_service.manual_fallback);
|
|
145
|
-
END $$;
|
|
146
|
-
|
|
147
|
-
COMMENT ON FUNCTION public.service_authorize(text) IS
|
|
148
|
-
'Whether this caller, in this tenant, may call this service (153). Answered before the request leaves, so a refusal costs no network and reads the same everywhere. `not_contracted` carries the entitlement so the caller can offer the upgrade instead of showing a dead button.';
|
|
149
|
-
|
|
150
|
-
GRANT EXECUTE ON FUNCTION public.service_authorize(text) TO authenticated, service_role;
|
|
151
|
-
|
|
152
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
153
|
-
-- §3 What was called, and what happened
|
|
154
|
-
--
|
|
155
|
-
-- Not an audit trail for its own sake: this is what answers "the button did
|
|
156
|
-
-- nothing" without asking the user to reproduce it, and what a quota is
|
|
157
|
-
-- eventually counted from.
|
|
158
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
159
|
-
|
|
160
|
-
CREATE TABLE IF NOT EXISTS app.service_calls (
|
|
161
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
162
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
163
|
-
service text NOT NULL,
|
|
164
|
-
caller uuid,
|
|
165
|
-
status text NOT NULL CHECK (status IN ('allowed', 'refused', 'ok', 'failed', 'timeout')),
|
|
166
|
-
reason text,
|
|
167
|
-
duration_ms integer,
|
|
168
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
COMMENT ON TABLE app.service_calls IS
|
|
172
|
-
'Every service call and what became of it (153). "The button did nothing" is answered here rather than by asking the user to do it again.';
|
|
173
|
-
|
|
174
|
-
CREATE INDEX IF NOT EXISTS service_calls_tenant_time ON app.service_calls (tenant_id, created_at DESC);
|
|
175
|
-
|
|
176
|
-
ALTER TABLE app.service_calls ENABLE ROW LEVEL SECURITY;
|
|
177
|
-
DROP POLICY IF EXISTS service_calls_read ON app.service_calls;
|
|
178
|
-
CREATE POLICY service_calls_read ON app.service_calls
|
|
179
|
-
FOR SELECT TO authenticated
|
|
180
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
181
|
-
GRANT SELECT ON app.service_calls TO authenticated;
|
|
182
|
-
GRANT ALL ON app.service_calls TO service_role;
|
|
183
|
-
|
|
184
|
-
CREATE OR REPLACE FUNCTION public.service_call_record(
|
|
185
|
-
p_service text, p_status text, p_reason text DEFAULT NULL, p_duration_ms integer DEFAULT NULL
|
|
186
|
-
) RETURNS uuid
|
|
187
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
188
|
-
AS $$
|
|
189
|
-
DECLARE
|
|
190
|
-
v_id uuid;
|
|
191
|
-
BEGIN
|
|
192
|
-
INSERT INTO app.service_calls (tenant_id, service, caller, status, reason, duration_ms)
|
|
193
|
-
VALUES (app.current_tenant_id(), p_service, auth.uid(), p_status, p_reason, p_duration_ms)
|
|
194
|
-
RETURNING id INTO v_id;
|
|
195
|
-
RETURN v_id;
|
|
196
|
-
END $$;
|
|
197
|
-
|
|
198
|
-
REVOKE ALL ON FUNCTION public.service_call_record(text, text, text, integer) FROM public, anon;
|
|
199
|
-
GRANT EXECUTE ON FUNCTION public.service_call_record(text, text, text, integer) TO authenticated, service_role;
|