@fayz-ai/db 0.21.0 → 0.22.2

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.
@@ -0,0 +1,366 @@
1
+ -- A empresa do V1 cabe na unidade.
2
+ --
3
+ -- O cadastro de empresa do V1 (Configurações → Empresa) é uma tela só com a
4
+ -- filial inteira: logo, sigla, classificação na rede, dois de cada contato,
5
+ -- canais de divulgação, configuração por filial e a identidade fiscal. No V2
6
+ -- a unidade tinha nome, tipo, um telefone e um e-mail — o resto não tinha onde
7
+ -- morar, e a tela de unidades não tinha o que mostrar.
8
+ --
9
+ -- Plano aprovado em docs/port/empresa.schema.json (port-schema). Três casas:
10
+ --
11
+ -- app.units o que é DA FILIAL (colunas novas)
12
+ -- public.company_legal_entities o que é DO CNPJ (razão social, IE, IM, CNAE…)
13
+ -- app.units.settings o que é CONFIGURAÇÃO por filial (127)
14
+ --
15
+ -- A ligação filial → CNPJ é app.units.company_legal_entity_id. A coluna
16
+ -- company_legal_entities.unit_id é o espelho e continua sendo preenchida, mas
17
+ -- quem decide é a unidade: duas filiais com o mesmo CNPJ apontam para a MESMA
18
+ -- pessoa jurídica (é o que o V1 faz com a matriz que emite pelas lojas).
19
+ --
20
+ -- E a porta é uma só: v_units passa a ler e escrever as três casas, para a
21
+ -- tela salvar a empresa inteira num gesto, como no V1 (140: a unidade se
22
+ -- escreve pela mesma porta que se lê).
23
+ --
24
+ -- Endereço e coordenada continuam em public.addresses (141/142). A lista da
25
+ -- vez continua em plg_agenda_turn_queues. Idempotente.
26
+
27
+ -- ═══════════════════════════════════════════════════════════════════════════
28
+ -- 1. O que é da filial
29
+ -- ═══════════════════════════════════════════════════════════════════════════
30
+
31
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS logo_url text;
32
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS abbreviation text;
33
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS coordinator_person_id uuid;
34
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS store_type text;
35
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS store_category text;
36
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS phone_2 text;
37
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS mobile_1 text;
38
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS mobile_2 text;
39
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS email_2 text;
40
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS instagram_handle text;
41
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS facebook_handle text;
42
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS tiktok_handle text;
43
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS website text;
44
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS whatsapp_number text;
45
+ ALTER TABLE app.units ADD COLUMN IF NOT EXISTS marketing_phone text;
46
+
47
+ DO $$
48
+ BEGIN
49
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'units_coordinator_person_fk') THEN
50
+ ALTER TABLE app.units
51
+ ADD CONSTRAINT units_coordinator_person_fk
52
+ FOREIGN KEY (coordinator_person_id) REFERENCES public.people(id) ON DELETE SET NULL;
53
+ END IF;
54
+ END $$;
55
+
56
+ COMMENT ON COLUMN app.units.logo_url IS 'V1: companies.logo_url — logo da filial.';
57
+ COMMENT ON COLUMN app.units.abbreviation IS 'V1: companies.abbreviation — sigla curta. Não é metadata.code (código interno).';
58
+ COMMENT ON COLUMN app.units.coordinator_person_id IS 'V1: companies.coordinator (texto) — no V2 aponta para a pessoa.';
59
+ COMMENT ON COLUMN app.units.store_type IS 'V1: companies.store_type — "Rua", "Shopping"; texto com sugestões dos valores já usados.';
60
+ COMMENT ON COLUMN app.units.store_category IS 'V1: companies.store_category — "A", "B", "C"; alimenta filtros de relatório de rede.';
61
+ COMMENT ON COLUMN app.units.phone_2 IS 'V1: companies.phone_2 (phone_1 = metadata.phone).';
62
+ COMMENT ON COLUMN app.units.mobile_1 IS 'V1: companies.mobile_1';
63
+ COMMENT ON COLUMN app.units.mobile_2 IS 'V1: companies.mobile_2';
64
+ COMMENT ON COLUMN app.units.email_2 IS 'V1: companies.email_2 (email_1 = metadata.email).';
65
+ COMMENT ON COLUMN app.units.instagram_handle IS 'V1: companies.instagram_handle — canal de divulgação.';
66
+ COMMENT ON COLUMN app.units.facebook_handle IS 'V1: companies.facebook_handle';
67
+ COMMENT ON COLUMN app.units.tiktok_handle IS 'V1: companies.tiktok_handle';
68
+ COMMENT ON COLUMN app.units.website IS 'V1: companies.website';
69
+ COMMENT ON COLUMN app.units.whatsapp_number IS 'V1: companies.whatsapp_number — WhatsApp de divulgação, não o canal do agente.';
70
+ COMMENT ON COLUMN app.units.marketing_phone IS 'V1: companies.marketing_phone';
71
+
72
+ -- ═══════════════════════════════════════════════════════════════════════════
73
+ -- 2. O que é do CNPJ
74
+ -- ═══════════════════════════════════════════════════════════════════════════
75
+
76
+ ALTER TABLE public.company_legal_entities ADD COLUMN IF NOT EXISTS cnae text;
77
+ ALTER TABLE public.company_legal_entities ADD COLUMN IF NOT EXISTS ibge_city_code text;
78
+ ALTER TABLE public.company_legal_entities ADD COLUMN IF NOT EXISTS fiscal_email text;
79
+
80
+ COMMENT ON COLUMN public.company_legal_entities.cnae IS 'V1: companies.cnae — CNAE principal.';
81
+ COMMENT ON COLUMN public.company_legal_entities.ibge_city_code IS 'V1: companies.codigo_ibge — o emissor de NFS-e roteia pela prefeitura.';
82
+ COMMENT ON COLUMN public.company_legal_entities.fiscal_email IS 'V1: companies.fiscal_email — retorno da prefeitura/SEFAZ, não o contato.';
83
+
84
+ -- ═══════════════════════════════════════════════════════════════════════════
85
+ -- 3. O que é configuração por filial (127)
86
+ -- ═══════════════════════════════════════════════════════════════════════════
87
+ -- `timezone` já existia como chave do tenant; passa a descer até a filial
88
+ -- (uma rede com loja em Manaus). O valor do tenant continua sendo o padrão.
89
+
90
+ SELECT app.seed_setting_keys($$[
91
+ { "key": "timezone", "type": "string", "default": "America/Sao_Paulo", "scope": "unit",
92
+ "description": "Fuso horário. O da casa é o padrão; a filial pode ter o seu (V1: companies.timezone)." },
93
+ { "key": "daylight_saving", "type": "boolean", "default": false, "scope": "unit",
94
+ "description": "Aplicar horário de verão nesta filial (V1: companies.apply_daylight_saving)." },
95
+ { "key": "phone.auto_ddd", "type": "boolean", "default": false, "scope": "unit",
96
+ "description": "Telefone digitado sem DDD recebe o DDD da filial (V1: companies.auto_ddd)." },
97
+ { "key": "booking.online_enabled", "type": "boolean", "default": true, "scope": "unit",
98
+ "description": "A filial aparece no agendamento online (V1: companies.show_in_online_booking)." },
99
+ { "key": "pos.require_open_cash_session", "type": "boolean", "default": false, "scope": "unit",
100
+ "description": "Maquininha e Balcão recusam a cobrança de quem não abriu o próprio caixinha nesta filial (V1: companies.pos_exige_caixinha)." }
101
+ ]$$::jsonb);
102
+
103
+ -- O valor que a tela mostra é o EFETIVO (filial → casa → padrão); o que se
104
+ -- grava na filial é só a diferença. Salvar a empresa sem mexer na
105
+ -- configuração não cria 5 exceções por filial.
106
+ CREATE OR REPLACE FUNCTION app.unit_setting_put(p_unit uuid, p_settings jsonb, p_key text, p_value jsonb)
107
+ RETURNS jsonb
108
+ LANGUAGE plpgsql
109
+ STABLE
110
+ SET search_path TO ''
111
+ AS $function$
112
+ DECLARE
113
+ v_tenant uuid;
114
+ v_house jsonb;
115
+ BEGIN
116
+ IF p_value IS NULL OR p_value = 'null'::jsonb THEN RETURN p_settings; END IF;
117
+ SELECT u.tenant_id INTO v_tenant FROM app.units u WHERE u.id = p_unit;
118
+ v_house := app.unit_setting_value(v_tenant, NULL, p_key);
119
+ IF v_house IS NOT DISTINCT FROM p_value THEN
120
+ RETURN app.jsonb_set_path(coalesce(p_settings, '{}'::jsonb), string_to_array(p_key, '.'), NULL);
121
+ END IF;
122
+ RETURN app.jsonb_set_path(coalesce(p_settings, '{}'::jsonb), string_to_array(p_key, '.'), p_value);
123
+ END $function$;
124
+
125
+ REVOKE ALL ON FUNCTION app.unit_setting_put(uuid, jsonb, text, jsonb) FROM PUBLIC, anon;
126
+ GRANT EXECUTE ON FUNCTION app.unit_setting_put(uuid, jsonb, text, jsonb) TO authenticated, service_role;
127
+
128
+ -- ═══════════════════════════════════════════════════════════════════════════
129
+ -- 4. A porta: v_units lê e escreve as três casas
130
+ -- ═══════════════════════════════════════════════════════════════════════════
131
+ -- CREATE OR REPLACE só acrescenta coluna NO FIM — as 26 de antes ficam na
132
+ -- mesma ordem.
133
+
134
+ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker = true) AS
135
+ SELECT u.id,
136
+ u.tenant_id,
137
+ COALESCE(u.metadata ->> 'legacy_kind'::text, u.kind) AS kind,
138
+ u.name,
139
+ u.metadata ->> 'email'::text AS email,
140
+ u.metadata ->> 'phone'::text AS phone,
141
+ NULLIF(btrim(COALESCE(a.street, ''::text) ||
142
+ CASE
143
+ WHEN NULLIF(btrim(COALESCE(a.number, ''::text)), ''::text) IS NULL THEN ''::text
144
+ ELSE ', '::text || btrim(a.number)
145
+ END), ''::text) AS address,
146
+ a.city,
147
+ a.state,
148
+ COALESCE(a.country, 'BR'::text) AS country,
149
+ a.postal_code,
150
+ u.kind = 'hq'::text AS is_headquarters,
151
+ u.active AS is_active,
152
+ COALESCE(( SELECT array_agg(t.value) AS array_agg
153
+ FROM jsonb_array_elements_text(
154
+ CASE
155
+ WHEN jsonb_typeof(u.metadata -> 'tags'::text) = 'array'::text THEN u.metadata -> 'tags'::text
156
+ ELSE '[]'::jsonb
157
+ END) t(value)), '{}'::text[]) AS tags,
158
+ u.metadata ->> 'notes'::text AS notes,
159
+ u.metadata,
160
+ CASE
161
+ WHEN jsonb_typeof(u.metadata -> 'custom_fields'::text) = 'object'::text THEN u.metadata -> 'custom_fields'::text
162
+ ELSE '{}'::jsonb
163
+ END AS custom_fields,
164
+ CASE
165
+ WHEN (u.metadata ->> 'created_by'::text) ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'::text THEN (u.metadata ->> 'created_by'::text)::uuid
166
+ ELSE NULL::uuid
167
+ END AS created_by,
168
+ u.created_at,
169
+ u.updated_at,
170
+ u.parent_unit_id AS parent_id,
171
+ u.metadata ->> 'code'::text AS code,
172
+ a.latitude,
173
+ a.longitude,
174
+ -- 168: o que é da filial
175
+ u.logo_url,
176
+ u.abbreviation,
177
+ u.coordinator_person_id,
178
+ u.store_type,
179
+ u.store_category,
180
+ u.phone_2,
181
+ u.mobile_1,
182
+ u.mobile_2,
183
+ u.email_2,
184
+ u.instagram_handle,
185
+ u.facebook_handle,
186
+ u.tiktok_handle,
187
+ u.website,
188
+ u.whatsapp_number,
189
+ u.marketing_phone,
190
+ -- 168: o que é do CNPJ
191
+ u.company_legal_entity_id AS legal_entity_id,
192
+ le.legal_name,
193
+ le.document_number AS cnpj,
194
+ le.state_registration,
195
+ le.municipal_registration,
196
+ le.tax_regime,
197
+ le.cnae,
198
+ le.ibge_city_code,
199
+ le.fiscal_email,
200
+ -- 168: configuração efetiva (filial → casa → padrão)
201
+ app.unit_setting_value(u.tenant_id, u.id, 'timezone') #>> '{}' AS timezone,
202
+ (app.unit_setting_value(u.tenant_id, u.id, 'daylight_saving'))::text::boolean AS daylight_saving,
203
+ (app.unit_setting_value(u.tenant_id, u.id, 'phone.auto_ddd'))::text::boolean AS auto_ddd,
204
+ (app.unit_setting_value(u.tenant_id, u.id, 'booking.online_enabled'))::text::boolean AS online_booking_enabled,
205
+ (app.unit_setting_value(u.tenant_id, u.id, 'pos.require_open_cash_session'))::text::boolean AS require_open_cash_session,
206
+ -- endereço por partes, para o formulário (address acima é a linha pronta)
207
+ a.street AS address_street,
208
+ a.number AS address_number,
209
+ a.complement AS address_complement,
210
+ a.district AS address_district
211
+ FROM app.units u
212
+ LEFT JOIN LATERAL ( SELECT x.street, x.number, x.complement, x.district, x.city, x.state,
213
+ x.country, x.postal_code, x.latitude, x.longitude
214
+ FROM public.addresses x
215
+ WHERE x.owner_type = 'unit'::text AND x.owner_id = u.id
216
+ ORDER BY x.is_default DESC, x.created_at
217
+ LIMIT 1) a ON true
218
+ LEFT JOIN public.company_legal_entities le
219
+ ON le.id = u.company_legal_entity_id AND le.tenant_id = u.tenant_id;
220
+
221
+ CREATE OR REPLACE FUNCTION app.trg_v_units_write()
222
+ RETURNS trigger
223
+ LANGUAGE plpgsql
224
+ SET search_path TO ''
225
+ AS $function$
226
+ DECLARE
227
+ -- Endereço (141) e coordenada (142) saíram daqui: são colunas de
228
+ -- public.addresses, e aceitá-las recriaria a segunda verdade.
229
+ k_managed constant text[] := ARRAY['email','phone','notes','code','created_by'];
230
+ k_moved constant text[] := ARRAY['address','city','state','country','postal_code','latitude','longitude'];
231
+ v_meta jsonb;
232
+ v_overlay jsonb;
233
+ v_kind text;
234
+ v_legacy text;
235
+ v_tenant uuid;
236
+ v_id uuid;
237
+ v_le uuid;
238
+ v_doc text;
239
+ v_settings jsonb;
240
+ BEGIN
241
+ IF TG_OP = 'DELETE' THEN
242
+ DELETE FROM public.addresses a WHERE a.owner_type = 'unit' AND a.owner_id = OLD.id;
243
+ DELETE FROM app.units u WHERE u.id = OLD.id;
244
+ RETURN OLD;
245
+ END IF;
246
+
247
+ v_meta := coalesce(NEW.metadata, '{}'::jsonb);
248
+
249
+ v_legacy := nullif(btrim(coalesce(NEW.kind, '')), '');
250
+ IF coalesce(NEW.is_headquarters, false) OR v_legacy = 'hq' THEN
251
+ v_kind := 'hq';
252
+ ELSE
253
+ v_kind := 'branch';
254
+ END IF;
255
+ IF v_legacy IN ('hq', 'branch') OR v_legacy IS NULL THEN v_legacy := NULL; END IF;
256
+
257
+ v_overlay := jsonb_strip_nulls(jsonb_build_object(
258
+ 'email', nullif(btrim(coalesce(NEW.email, '')), ''),
259
+ 'phone', nullif(btrim(coalesce(NEW.phone, '')), ''),
260
+ 'notes', nullif(btrim(coalesce(NEW.notes, '')), ''),
261
+ 'code', nullif(btrim(coalesce(NEW.code, '')), ''),
262
+ 'created_by', CASE WHEN NEW.created_by IS NULL THEN NULL ELSE NEW.created_by::text END
263
+ ));
264
+
265
+ v_meta := (v_meta - k_managed - k_moved) || v_overlay;
266
+
267
+ IF v_legacy IS NULL THEN v_meta := v_meta - 'legacy_kind';
268
+ ELSE v_meta := jsonb_set(v_meta, '{legacy_kind}', to_jsonb(v_legacy)); END IF;
269
+
270
+ IF NEW.tags IS NULL OR cardinality(NEW.tags) = 0 THEN v_meta := v_meta - 'tags';
271
+ ELSE v_meta := jsonb_set(v_meta, '{tags}', to_jsonb(NEW.tags)); END IF;
272
+
273
+ IF NEW.custom_fields IS NULL OR NEW.custom_fields = '{}'::jsonb THEN v_meta := v_meta - 'custom_fields';
274
+ ELSE v_meta := jsonb_set(v_meta, '{custom_fields}', NEW.custom_fields); END IF;
275
+
276
+ IF TG_OP = 'INSERT' THEN
277
+ v_tenant := coalesce(NEW.tenant_id, app.current_tenant_id());
278
+ INSERT INTO app.units (id, tenant_id, kind, name, parent_unit_id, active, metadata)
279
+ VALUES (coalesce(NEW.id, gen_random_uuid()), v_tenant, v_kind, btrim(NEW.name),
280
+ NEW.parent_id, coalesce(NEW.is_active, true), v_meta)
281
+ RETURNING id INTO v_id;
282
+ ELSE
283
+ UPDATE app.units u
284
+ SET kind = v_kind,
285
+ name = btrim(NEW.name),
286
+ parent_unit_id = NEW.parent_id,
287
+ active = coalesce(NEW.is_active, true),
288
+ metadata = v_meta,
289
+ updated_at = now()
290
+ WHERE u.id = OLD.id
291
+ RETURNING u.id, u.tenant_id INTO v_id, v_tenant;
292
+ IF v_id IS NULL THEN
293
+ RAISE EXCEPTION 'v_units: unit % not found or not yours to change', OLD.id USING ERRCODE = '42501';
294
+ END IF;
295
+ END IF;
296
+
297
+ -- 168: o que é da filial
298
+ UPDATE app.units u
299
+ SET logo_url = nullif(btrim(coalesce(NEW.logo_url, '')), ''),
300
+ abbreviation = nullif(btrim(coalesce(NEW.abbreviation, '')), ''),
301
+ coordinator_person_id = NEW.coordinator_person_id,
302
+ store_type = nullif(btrim(coalesce(NEW.store_type, '')), ''),
303
+ store_category = nullif(btrim(coalesce(NEW.store_category, '')), ''),
304
+ phone_2 = nullif(btrim(coalesce(NEW.phone_2, '')), ''),
305
+ mobile_1 = nullif(btrim(coalesce(NEW.mobile_1, '')), ''),
306
+ mobile_2 = nullif(btrim(coalesce(NEW.mobile_2, '')), ''),
307
+ email_2 = nullif(btrim(coalesce(NEW.email_2, '')), ''),
308
+ instagram_handle = nullif(btrim(coalesce(NEW.instagram_handle, '')), ''),
309
+ facebook_handle = nullif(btrim(coalesce(NEW.facebook_handle, '')), ''),
310
+ tiktok_handle = nullif(btrim(coalesce(NEW.tiktok_handle, '')), ''),
311
+ website = nullif(btrim(coalesce(NEW.website, '')), ''),
312
+ whatsapp_number = nullif(btrim(coalesce(NEW.whatsapp_number, '')), ''),
313
+ marketing_phone = nullif(btrim(coalesce(NEW.marketing_phone, '')), '')
314
+ WHERE u.id = v_id;
315
+
316
+ -- 168: configuração — só a diferença do padrão da casa desce para a filial
317
+ SELECT u.settings INTO v_settings FROM app.units u WHERE u.id = v_id;
318
+ v_settings := app.unit_setting_put(v_id, v_settings, 'timezone', to_jsonb(nullif(btrim(coalesce(NEW.timezone, '')), '')));
319
+ v_settings := app.unit_setting_put(v_id, v_settings, 'daylight_saving', to_jsonb(NEW.daylight_saving));
320
+ v_settings := app.unit_setting_put(v_id, v_settings, 'phone.auto_ddd', to_jsonb(NEW.auto_ddd));
321
+ v_settings := app.unit_setting_put(v_id, v_settings, 'booking.online_enabled', to_jsonb(NEW.online_booking_enabled));
322
+ v_settings := app.unit_setting_put(v_id, v_settings, 'pos.require_open_cash_session', to_jsonb(NEW.require_open_cash_session));
323
+ UPDATE app.units u SET settings = v_settings WHERE u.id = v_id AND u.settings IS DISTINCT FROM v_settings;
324
+
325
+ -- 168: o que é do CNPJ. A filial aponta para uma pessoa jurídica; se ela
326
+ -- ainda não aponta e o CNPJ digitado já existe na casa, liga na existente
327
+ -- em vez de criar a segunda.
328
+ v_doc := nullif(regexp_replace(coalesce(NEW.cnpj, ''), '\D', '', 'g'), '');
329
+ SELECT u.company_legal_entity_id INTO v_le FROM app.units u WHERE u.id = v_id;
330
+ IF v_le IS NULL AND v_doc IS NOT NULL THEN
331
+ SELECT le.id INTO v_le FROM public.company_legal_entities le
332
+ WHERE le.tenant_id = v_tenant AND le.document_number = v_doc
333
+ ORDER BY le.created_at LIMIT 1;
334
+ END IF;
335
+ IF v_le IS NULL AND (nullif(btrim(coalesce(NEW.legal_name, '')), '') IS NOT NULL OR v_doc IS NOT NULL) THEN
336
+ INSERT INTO public.company_legal_entities (tenant_id, kind, legal_name, trade_name, document_number, unit_id)
337
+ VALUES (v_tenant, CASE WHEN length(v_doc) = 11 THEN 'individual' ELSE 'company' END,
338
+ coalesce(nullif(btrim(coalesce(NEW.legal_name, '')), ''), btrim(NEW.name)), btrim(NEW.name), v_doc, v_id)
339
+ RETURNING id INTO v_le;
340
+ END IF;
341
+ IF v_le IS NOT NULL THEN
342
+ UPDATE public.company_legal_entities le
343
+ SET legal_name = coalesce(nullif(btrim(coalesce(NEW.legal_name, '')), ''), le.legal_name),
344
+ trade_name = coalesce(le.trade_name, btrim(NEW.name)),
345
+ document_number = v_doc,
346
+ kind = CASE WHEN length(v_doc) = 11 THEN 'individual' ELSE 'company' END,
347
+ state_registration = nullif(btrim(coalesce(NEW.state_registration, '')), ''),
348
+ municipal_registration = nullif(btrim(coalesce(NEW.municipal_registration, '')), ''),
349
+ tax_regime = nullif(btrim(coalesce(NEW.tax_regime, '')), ''),
350
+ cnae = nullif(btrim(coalesce(NEW.cnae, '')), ''),
351
+ ibge_city_code = nullif(btrim(coalesce(NEW.ibge_city_code, '')), ''),
352
+ fiscal_email = nullif(btrim(coalesce(NEW.fiscal_email, '')), ''),
353
+ unit_id = coalesce(le.unit_id, v_id),
354
+ updated_at = now()
355
+ WHERE le.id = v_le;
356
+ UPDATE app.units u SET company_legal_entity_id = v_le
357
+ WHERE u.id = v_id AND u.company_legal_entity_id IS DISTINCT FROM v_le;
358
+ END IF;
359
+
360
+ SELECT * INTO NEW FROM public.v_units WHERE id = v_id;
361
+ RETURN NEW;
362
+ END $function$;
363
+
364
+ -- 157: a função do trigger não se chama de fora.
365
+ REVOKE ALL ON FUNCTION app.trg_v_units_write() FROM PUBLIC, anon, authenticated;
366
+ GRANT EXECUTE ON FUNCTION app.trg_v_units_write() TO service_role;
@@ -0,0 +1,65 @@
1
+ -- A configuração da filial se lê pela porta da sessão.
2
+ --
3
+ -- 168 pôs na v_units a configuração efetiva chamando app.unit_setting_value —
4
+ -- que é SECURITY DEFINER, recebe tenant e unidade como argumento e por isso
5
+ -- NÃO é executável por authenticated (abrir seria ler a configuração de
6
+ -- qualquer tenant). Como a v_units é security_invoker, a view inteira passou a
7
+ -- negar 42501 para quem usa o app.
8
+ --
9
+ -- A saída não é abrir a função: são duas de leitura que tiram tenant da
10
+ -- SESSÃO e conferem o alcance da unidade, e devolvem nulo para o resto.
11
+ -- Idempotente.
12
+
13
+ CREATE OR REPLACE FUNCTION app.my_unit_setting(p_unit uuid, p_key text)
14
+ RETURNS jsonb
15
+ LANGUAGE sql
16
+ STABLE SECURITY DEFINER
17
+ SET search_path TO ''
18
+ AS $function$
19
+ SELECT CASE
20
+ WHEN EXISTS (SELECT 1 FROM app.units u
21
+ WHERE u.id = p_unit AND u.tenant_id = app.current_tenant_id())
22
+ AND app.has_unit(p_unit)
23
+ THEN app.unit_setting_value(app.current_tenant_id(), p_unit, p_key)
24
+ END;
25
+ $function$;
26
+
27
+ CREATE OR REPLACE FUNCTION app.my_house_setting(p_key text)
28
+ RETURNS jsonb
29
+ LANGUAGE sql
30
+ STABLE SECURITY DEFINER
31
+ SET search_path TO ''
32
+ AS $function$
33
+ SELECT CASE WHEN app.current_tenant_id() IS NOT NULL
34
+ THEN app.unit_setting_value(app.current_tenant_id(), NULL, p_key) END;
35
+ $function$;
36
+
37
+ REVOKE ALL ON FUNCTION app.my_unit_setting(uuid, text) FROM PUBLIC, anon;
38
+ REVOKE ALL ON FUNCTION app.my_house_setting(text) FROM PUBLIC, anon;
39
+ GRANT EXECUTE ON FUNCTION app.my_unit_setting(uuid, text) TO authenticated, service_role;
40
+ GRANT EXECUTE ON FUNCTION app.my_house_setting(text) TO authenticated, service_role;
41
+
42
+ CREATE OR REPLACE FUNCTION app.unit_setting_put(p_unit uuid, p_settings jsonb, p_key text, p_value jsonb)
43
+ RETURNS jsonb
44
+ LANGUAGE plpgsql
45
+ STABLE
46
+ SET search_path TO ''
47
+ AS $function$
48
+ BEGIN
49
+ IF p_value IS NULL OR p_value = 'null'::jsonb THEN RETURN p_settings; END IF;
50
+ IF app.my_house_setting(p_key) IS NOT DISTINCT FROM p_value THEN
51
+ RETURN app.jsonb_set_path(coalesce(p_settings, '{}'::jsonb), string_to_array(p_key, '.'), NULL);
52
+ END IF;
53
+ RETURN app.jsonb_set_path(coalesce(p_settings, '{}'::jsonb), string_to_array(p_key, '.'), p_value);
54
+ END $function$;
55
+
56
+ -- Mesma view da 168, só trocando a função de leitura das 5 configurações.
57
+ DO $$
58
+ DECLARE v_def text;
59
+ BEGIN
60
+ v_def := pg_get_viewdef('public.v_units'::regclass, true);
61
+ IF position('app.unit_setting_value(' in v_def) > 0 THEN
62
+ v_def := replace(v_def, 'app.unit_setting_value(u.tenant_id, u.id, ', 'app.my_unit_setting(u.id, ');
63
+ EXECUTE 'CREATE OR REPLACE VIEW public.v_units WITH (security_invoker = true) AS ' || v_def;
64
+ END IF;
65
+ END $$;
@@ -0,0 +1,55 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 171 — a validade informada não se apaga sozinha.
3
+ --
4
+ -- A 166 trocou "validade em dias" por "validade em meses, com um interruptor",
5
+ -- e pôs um gatilho para manter os dois espelhos de acordo. O gatilho está
6
+ -- certo no UPDATE e errado no INSERT:
7
+ --
8
+ -- BEFORE INSERT OR UPDATE OF validity_enabled, validity_months
9
+ --
10
+ -- A lista `OF` só recorta o UPDATE. No INSERT ele dispara sempre — e como
11
+ -- `validity_enabled` nasce `false` por DEFAULT, o ramo `ELSIF NOT
12
+ -- NEW.validity_enabled` zera `validity_days` de QUALQUER linha nova. Uma linha
13
+ -- que chega dizendo «validade: 180 dias» é gravada sem validade nenhuma.
14
+ --
15
+ -- Quem escreve `validity_days` direto é o porte do V1, que é de onde vêm os
16
+ -- pacotes todos. A suíte do ledger pegou: `900.00/180` virou nulo.
17
+ --
18
+ -- O conserto é ler a intenção de quem insere em vez de impor o default. Se a
19
+ -- linha chega com dias e sem meses, ela QUIS validade — então os meses se
20
+ -- derivam e o interruptor liga, que é exatamente o que o backfill da própria
21
+ -- 166 fez com as linhas antigas. Apagar continua sendo possível, mas só quando
22
+ -- alguém desliga o interruptor de propósito.
23
+ --
24
+ -- Idempotente: só repõe a função.
25
+ -- ---------------------------------------------------------------------------
26
+
27
+ CREATE OR REPLACE FUNCTION public.service_packages_sync_validity()
28
+ RETURNS trigger
29
+ LANGUAGE plpgsql
30
+ SECURITY INVOKER
31
+ SET search_path = ''
32
+ AS $$
33
+ BEGIN
34
+ -- A linha nova que traz dias e não traz meses: é validade declarada, não
35
+ -- resíduo. Mesma conta do backfill da 166.
36
+ IF TG_OP = 'INSERT'
37
+ AND NOT NEW.validity_enabled
38
+ AND NEW.validity_days IS NOT NULL
39
+ AND NEW.validity_months IS NULL THEN
40
+ NEW.validity_enabled := true;
41
+ NEW.validity_months := GREATEST(1, round(NEW.validity_days / 30.0)::integer);
42
+ RETURN NEW;
43
+ END IF;
44
+
45
+ IF NEW.validity_enabled AND NEW.validity_months IS NOT NULL THEN
46
+ NEW.validity_days := NEW.validity_months * 30;
47
+ ELSIF NOT NEW.validity_enabled THEN
48
+ NEW.validity_days := NULL;
49
+ NEW.validity_months := NULL;
50
+ END IF;
51
+ RETURN NEW;
52
+ END $$;
53
+
54
+ COMMENT ON FUNCTION public.service_packages_sync_validity() IS
55
+ 'Mantém validity_days e validity_months de acordo. No INSERT, dias informados sem meses são validade DECLARADA: ligam o interruptor em vez de serem apagados pelo default (171).';
@@ -0,0 +1,23 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 172 — o pacote pode repetir o mesmo serviço em linhas diferentes.
3
+ --
4
+ -- `service_package_items_unique (package_id, product_id)` veio do porte e
5
+ -- barrava o que a grade do pacote precisa fazer: duplicar uma linha (o mesmo
6
+ -- serviço com outro valor no pacote — a primeira sessão cheia, as demais com
7
+ -- desconto) e trocar o serviço de uma linha para um que já está em outra.
8
+ -- O erro chegava cru na tela: «duplicate key value violates unique constraint».
9
+ --
10
+ -- Nada lia a unicidade: nenhum ON CONFLICT, e quem consome o pacote (venda,
11
+ -- saldo de sessões) itera as linhas. A soma da 166 já é por linha.
12
+ --
13
+ -- O índice da constraint era o único que começava por `package_id`; a lista
14
+ -- do pacote filtra por ele, então um índice simples entra no lugar.
15
+ --
16
+ -- Idempotente e sem tocar em dado.
17
+ -- ---------------------------------------------------------------------------
18
+
19
+ ALTER TABLE public.service_package_items
20
+ DROP CONSTRAINT IF EXISTS service_package_items_unique;
21
+
22
+ CREATE INDEX IF NOT EXISTS service_package_items_package_idx
23
+ ON public.service_package_items (tenant_id, package_id);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.21.0",
6
+ "version": "0.22.2",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,