@fayz-ai/plugin-support 0.0.0-stage → 0.2.1

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/HelpdeskConsole-7W5IAQIU.js +355 -0
  3. package/dist/HelpdeskConsole-7W5IAQIU.js.map +1 -0
  4. package/dist/SupportCenter-Z2HEPJGD.js +1090 -0
  5. package/dist/SupportCenter-Z2HEPJGD.js.map +1 -0
  6. package/dist/chunk-5K7VA5VP.js +106 -0
  7. package/dist/chunk-5K7VA5VP.js.map +1 -0
  8. package/dist/chunk-7NVBUXTX.js +300 -0
  9. package/dist/chunk-7NVBUXTX.js.map +1 -0
  10. package/dist/chunk-JJPB2T3Z.js +131 -0
  11. package/dist/chunk-JJPB2T3Z.js.map +1 -0
  12. package/dist/components/HelpdeskStatusPanel.d.ts +12 -0
  13. package/dist/components/HelpdeskStatusPanel.d.ts.map +1 -0
  14. package/dist/data/api.d.ts +15 -0
  15. package/dist/data/api.d.ts.map +1 -0
  16. package/dist/data/console.d.ts +37 -0
  17. package/dist/data/console.d.ts.map +1 -0
  18. package/dist/index.d.ts +19 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +901 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/lib/content.d.ts +24 -0
  23. package/dist/lib/content.d.ts.map +1 -0
  24. package/dist/lib/fix-prompt.d.ts +30 -0
  25. package/dist/lib/fix-prompt.d.ts.map +1 -0
  26. package/dist/lib/report-tool.d.ts +25 -0
  27. package/dist/lib/report-tool.d.ts.map +1 -0
  28. package/dist/lib/vocabulary.d.ts +34 -0
  29. package/dist/lib/vocabulary.d.ts.map +1 -0
  30. package/dist/locales/en.d.ts +2 -0
  31. package/dist/locales/en.d.ts.map +1 -0
  32. package/dist/locales/index.d.ts +2 -0
  33. package/dist/locales/index.d.ts.map +1 -0
  34. package/dist/locales/pt-BR.d.ts +2 -0
  35. package/dist/locales/pt-BR.d.ts.map +1 -0
  36. package/dist/migrations/index.d.ts +9 -0
  37. package/dist/migrations/index.d.ts.map +1 -0
  38. package/dist/store.d.ts +27 -0
  39. package/dist/store.d.ts.map +1 -0
  40. package/dist/types.d.ts +80 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/views/HelpdeskConsole.d.ts +10 -0
  43. package/dist/views/HelpdeskConsole.d.ts.map +1 -0
  44. package/dist/views/ScreenCaptureEditor.d.ts +12 -0
  45. package/dist/views/ScreenCaptureEditor.d.ts.map +1 -0
  46. package/dist/views/SupportCenter.d.ts +14 -0
  47. package/dist/views/SupportCenter.d.ts.map +1 -0
  48. package/dist/views/SupportMenu.d.ts +12 -0
  49. package/dist/views/SupportMenu.d.ts.map +1 -0
  50. package/dist/views/SupportRequestDetail.d.ts +6 -0
  51. package/dist/views/SupportRequestDetail.d.ts.map +1 -0
  52. package/dist/views/SupportRequestForm.d.ts +12 -0
  53. package/dist/views/SupportRequestForm.d.ts.map +1 -0
  54. package/dist/views/SupportRequestsPanel.d.ts +11 -0
  55. package/dist/views/SupportRequestsPanel.d.ts.map +1 -0
  56. package/dist/views/TicketDetailPage.d.ts +12 -0
  57. package/dist/views/TicketDetailPage.d.ts.map +1 -0
  58. package/package.json +56 -3
  59. package/README.md +0 -3
package/dist/index.js ADDED
@@ -0,0 +1,901 @@
1
+ import { defaultStatusComponents, defaultWhatsNew, defaultFaq, useSupportStore } from './chunk-JJPB2T3Z.js';
2
+ export { useSupportStore } from './chunk-JJPB2T3Z.js';
3
+ import { consoleListRequests } from './chunk-5K7VA5VP.js';
4
+ import { STATUS_LABEL, STATUS_TONE, createRequest, uploadAttachment, listRequests } from './chunk-7NVBUXTX.js';
5
+ export { MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, SUPPORT_BUCKET } from './chunk-7NVBUXTX.js';
6
+ import React2 from 'react';
7
+ import { registerTranslations, getRecentActivity, useNavHistoryStore } from '@fayz-ai/core';
8
+ import { Skeleton, Badge } from '@fayz-ai/ui';
9
+ import { jsx, jsxs } from 'react/jsx-runtime';
10
+
11
+ // src/locales/en.ts
12
+ var en = {
13
+ "support.title": "Support",
14
+ "support.nav.requests": "My requests",
15
+ "support.feature.base": "Support",
16
+ "support.feature.all": "Whole-company requests"
17
+ };
18
+
19
+ // src/locales/pt-BR.ts
20
+ var ptBR = {
21
+ "support.title": "Suporte",
22
+ "support.nav.requests": "Minhas solicita\xE7\xF5es",
23
+ "support.feature.base": "Suporte",
24
+ "support.feature.all": "Solicita\xE7\xF5es de toda a empresa"
25
+ };
26
+
27
+ // src/locales/index.ts
28
+ var supportLocales = {
29
+ en,
30
+ "pt-BR": ptBR
31
+ };
32
+
33
+ // src/migrations/index.ts
34
+ var MIGRATION_000_BASELINE = `-- plugin-support \u2014 Central de Suporte do Dico: the solicita\xE7\xF5es a tenant files
35
+ -- (problem / improvement / other), their conversation, history, attachments and
36
+ -- the "esperando voc\xEA" notifications. Port of the V1 support_* family.
37
+ --
38
+ -- Idempotent. Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
39
+ -- "Minhas vs Toda a empresa" is a query-layer filter on created_by, gated by the
40
+ -- support.all feature \u2014 not an RLS distinction (the fleet has no SQL permission
41
+ -- helper, and the cross-member triage read is the 2nd wave with the platform bridge).
42
+
43
+ SET check_function_bodies = false;
44
+
45
+ -- \u2500\u2500 tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46
+
47
+ -- The request itself. Carries, unseen by the person, the screen it was filed
48
+ -- from (route, active unit, a snapshot of what was visible) \u2014 triage reads it first.
49
+ CREATE TABLE IF NOT EXISTS public.plg_support_requests (
50
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
52
+ number integer NOT NULL, -- per-tenant sequential #; filled by trigger
53
+ kind text NOT NULL, -- problem | improvement | other
54
+ subject text, -- optional; derived from first line when blank
55
+ description text NOT NULL,
56
+ status text NOT NULL DEFAULT 'received', -- received|in_service|waiting_you|in_development|resolved_check|closed|cancelled
57
+ route text, -- origin screen hash path
58
+ unit_id uuid, -- active unit when filed (informational snapshot, no FK)
59
+ screen_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
60
+ origin_conversation_id uuid, -- the Dico chat that spawned it, when any
61
+ area text,
62
+ created_by uuid NOT NULL DEFAULT auth.uid(), -- auth user; the owner of "Minhas". Default so the client cannot spoof it.
63
+ created_at timestamptz NOT NULL DEFAULT now(),
64
+ updated_at timestamptz NOT NULL DEFAULT now(),
65
+ resolved_at timestamptz,
66
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb
67
+ );
68
+ ALTER TABLE public.plg_support_requests ENABLE ROW LEVEL SECURITY;
69
+ CREATE INDEX IF NOT EXISTS idx_plg_support_requests_tenant ON public.plg_support_requests(tenant_id);
70
+ CREATE INDEX IF NOT EXISTS idx_plg_support_requests_tenant_created_by ON public.plg_support_requests(tenant_id, created_by);
71
+ CREATE INDEX IF NOT EXISTS idx_plg_support_requests_tenant_status ON public.plg_support_requests(tenant_id, status);
72
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_support_requests_tenant_number ON public.plg_support_requests(tenant_id, number);
73
+
74
+ -- The conversation inside the request ("Escreva para a equipe\u2026").
75
+ CREATE TABLE IF NOT EXISTS public.plg_support_request_messages (
76
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
77
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
78
+ request_id uuid NOT NULL REFERENCES public.plg_support_requests(id) ON DELETE CASCADE,
79
+ author_kind text NOT NULL DEFAULT 'user', -- user | team
80
+ author_id uuid DEFAULT auth.uid(),
81
+ body text NOT NULL,
82
+ created_at timestamptz NOT NULL DEFAULT now()
83
+ );
84
+ ALTER TABLE public.plg_support_request_messages ENABLE ROW LEVEL SECURITY;
85
+ CREATE INDEX IF NOT EXISTS idx_plg_support_request_messages_tenant ON public.plg_support_request_messages(tenant_id);
86
+ CREATE INDEX IF NOT EXISTS idx_plg_support_request_messages_request ON public.plg_support_request_messages(request_id, created_at);
87
+
88
+ -- The short-sentence history shown in the detail ("Solicita\xE7\xE3o aberta", "Andamento: Cancelada").
89
+ CREATE TABLE IF NOT EXISTS public.plg_support_request_events (
90
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
91
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
92
+ request_id uuid NOT NULL REFERENCES public.plg_support_requests(id) ON DELETE CASCADE,
93
+ type text NOT NULL, -- opened|status_changed|validated_ok|reopened|cancelled|closed_no_reply|merged
94
+ note text,
95
+ created_at timestamptz NOT NULL DEFAULT now()
96
+ );
97
+ ALTER TABLE public.plg_support_request_events ENABLE ROW LEVEL SECURITY;
98
+ CREATE INDEX IF NOT EXISTS idx_plg_support_request_events_tenant ON public.plg_support_request_events(tenant_id);
99
+ CREATE INDEX IF NOT EXISTS idx_plg_support_request_events_request ON public.plg_support_request_events(request_id, created_at);
100
+
101
+ -- Attachment pointers; the file itself lives in the support-attachments bucket.
102
+ CREATE TABLE IF NOT EXISTS public.plg_support_request_attachments (
103
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
104
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
105
+ request_id uuid NOT NULL REFERENCES public.plg_support_requests(id) ON DELETE CASCADE,
106
+ storage_path text NOT NULL, -- <tenant_id>/<request_id>/<uuid>/<name>
107
+ name text NOT NULL,
108
+ size_bytes bigint,
109
+ content_type text,
110
+ created_by uuid DEFAULT auth.uid(),
111
+ created_at timestamptz NOT NULL DEFAULT now()
112
+ );
113
+ ALTER TABLE public.plg_support_request_attachments ENABLE ROW LEVEL SECURITY;
114
+ CREATE INDEX IF NOT EXISTS idx_plg_support_request_attachments_tenant ON public.plg_support_request_attachments(tenant_id);
115
+ CREATE INDEX IF NOT EXISTS idx_plg_support_request_attachments_request ON public.plg_support_request_attachments(request_id);
116
+
117
+ -- The orange "Esperando voc\xEA" counter and the bell notice when the team replies.
118
+ CREATE TABLE IF NOT EXISTS public.plg_support_notifications (
119
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
120
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
121
+ request_id uuid NOT NULL REFERENCES public.plg_support_requests(id) ON DELETE CASCADE,
122
+ user_id uuid NOT NULL, -- who should see it
123
+ kind text, -- reply | resolved | waiting_you
124
+ read_at timestamptz, -- NULL = unread (counts in the orange badge)
125
+ created_at timestamptz NOT NULL DEFAULT now()
126
+ );
127
+ ALTER TABLE public.plg_support_notifications ENABLE ROW LEVEL SECURITY;
128
+ CREATE INDEX IF NOT EXISTS idx_plg_support_notifications_tenant ON public.plg_support_notifications(tenant_id);
129
+ CREATE INDEX IF NOT EXISTS idx_plg_support_notifications_user_unread ON public.plg_support_notifications(tenant_id, user_id, read_at);
130
+
131
+ -- \u2500\u2500 per-tenant sequential number \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
132
+ -- Each account counts its requests from #1. The advisory lock serialises the
133
+ -- read-then-write per tenant so two concurrent opens don't collide on the number.
134
+ CREATE OR REPLACE FUNCTION public.plg_support_assign_number()
135
+ RETURNS trigger
136
+ LANGUAGE plpgsql
137
+ AS $fn$
138
+ BEGIN
139
+ IF NEW.number IS NULL OR NEW.number = 0 THEN
140
+ PERFORM pg_advisory_xact_lock(hashtextextended('plg_support_requests:' || NEW.tenant_id::text, 0));
141
+ SELECT COALESCE(MAX(number), 0) + 1 INTO NEW.number
142
+ FROM public.plg_support_requests
143
+ WHERE tenant_id = NEW.tenant_id;
144
+ END IF;
145
+ RETURN NEW;
146
+ END;
147
+ $fn$;
148
+
149
+ DROP TRIGGER IF EXISTS trg_plg_support_assign_number ON public.plg_support_requests;
150
+ CREATE TRIGGER trg_plg_support_assign_number
151
+ BEFORE INSERT ON public.plg_support_requests
152
+ FOR EACH ROW EXECUTE FUNCTION public.plg_support_assign_number();
153
+
154
+ -- \u2500\u2500 touch updated_at \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
155
+ CREATE OR REPLACE FUNCTION public.plg_support_touch_updated_at()
156
+ RETURNS trigger
157
+ LANGUAGE plpgsql
158
+ AS $fn$
159
+ BEGIN
160
+ NEW.updated_at := now();
161
+ RETURN NEW;
162
+ END;
163
+ $fn$;
164
+
165
+ DROP TRIGGER IF EXISTS trg_plg_support_requests_touch ON public.plg_support_requests;
166
+ CREATE TRIGGER trg_plg_support_requests_touch
167
+ BEFORE UPDATE ON public.plg_support_requests
168
+ FOR EACH ROW EXECUTE FUNCTION public.plg_support_touch_updated_at();
169
+
170
+ -- \u2500\u2500 row security + grants (idempotent) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
171
+ DO $rls$
172
+ DECLARE t text;
173
+ BEGIN
174
+ FOR t IN SELECT unnest(ARRAY[
175
+ 'plg_support_requests',
176
+ 'plg_support_request_messages',
177
+ 'plg_support_request_events',
178
+ 'plg_support_request_attachments',
179
+ 'plg_support_notifications'
180
+ ])
181
+ LOOP
182
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
183
+ EXECUTE format('GRANT ALL ON public.%I TO authenticated', t);
184
+ EXECUTE format('GRANT ALL ON public.%I TO service_role', t);
185
+ BEGIN
186
+ EXECUTE format('GRANT MAINTAIN ON public.%I TO anon', t);
187
+ EXCEPTION WHEN insufficient_privilege OR undefined_object OR feature_not_supported THEN
188
+ RAISE NOTICE 'plg_support: skipped GRANT MAINTAIN on % (% )', t, SQLERRM;
189
+ END;
190
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
191
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
192
+ END IF;
193
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
194
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
195
+ END IF;
196
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
197
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
198
+ END IF;
199
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
200
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
201
+ END IF;
202
+ END LOOP;
203
+ END $rls$;
204
+
205
+ -- \u2500\u2500 attachments bucket (private; read by tenant folder) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
206
+ -- Path convention: <tenant_id>/<request_id>/<uuid>/<name>. A captura de tela
207
+ -- pode ter nome e valor de cliente borrados mas ainda sens\xEDvel \u2014 bucket privado,
208
+ -- servido por URL assinada de curta dura\xE7\xE3o; quem n\xE3o \xE9 do tenant n\xE3o alcan\xE7a.
209
+ INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
210
+ VALUES ('support-attachments', 'support-attachments', false, 20971520,
211
+ ARRAY['image/jpeg','image/png','image/webp','image/gif','application/pdf',
212
+ 'text/plain','text/csv','audio/mpeg','audio/webm','video/mp4','video/webm'])
213
+ ON CONFLICT (id) DO UPDATE SET
214
+ public = EXCLUDED.public,
215
+ file_size_limit = EXCLUDED.file_size_limit,
216
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
217
+
218
+ DROP POLICY IF EXISTS plg_support_attachments_read ON storage.objects;
219
+ DROP POLICY IF EXISTS plg_support_attachments_insert ON storage.objects;
220
+ DROP POLICY IF EXISTS plg_support_attachments_update ON storage.objects;
221
+ DROP POLICY IF EXISTS plg_support_attachments_delete ON storage.objects;
222
+
223
+ CREATE POLICY plg_support_attachments_read ON storage.objects
224
+ FOR SELECT TO authenticated
225
+ USING (bucket_id = 'support-attachments'
226
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text));
227
+
228
+ CREATE POLICY plg_support_attachments_insert ON storage.objects
229
+ FOR INSERT TO authenticated
230
+ WITH CHECK (bucket_id = 'support-attachments'
231
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text));
232
+
233
+ CREATE POLICY plg_support_attachments_update ON storage.objects
234
+ FOR UPDATE TO authenticated
235
+ USING (bucket_id = 'support-attachments'
236
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text))
237
+ WITH CHECK (bucket_id = 'support-attachments'
238
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text));
239
+
240
+ CREATE POLICY plg_support_attachments_delete ON storage.objects
241
+ FOR DELETE TO authenticated
242
+ USING (bucket_id = 'support-attachments'
243
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text));
244
+ `;
245
+ var MIGRATION_001_HELPDESK = `-- plugin-support \u2014 2nd wave: the cross-tenant helpdesk the baseline foresaw
246
+ -- ("the cross-member triage read is the 2nd wave with the platform bridge").
247
+ --
248
+ -- GoControl is the helpdesk: a small ALLOW-LIST of operators reads and acts on
249
+ -- the support requests of EVERY tenant, through SECURITY DEFINER functions that
250
+ -- carry the tenant boundary themselves. The per-tenant RLS from the baseline is
251
+ -- untouched \u2014 a store still sees only its own requests. Only a seeded operator,
252
+ -- and only through these functions, crosses it.
253
+ --
254
+ -- Idempotent. Additive only (new columns, a new table, new functions).
255
+
256
+ SET check_function_bodies = false;
257
+
258
+ -- \u2500\u2500 new columns on the request \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
259
+ ALTER TABLE public.plg_support_requests
260
+ ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'normal'; -- low|normal|high|urgent
261
+ ALTER TABLE public.plg_support_requests
262
+ ADD COLUMN IF NOT EXISTS linked_lead_id uuid; -- a CRM lead/client the operator tied to it
263
+ ALTER TABLE public.plg_support_requests
264
+ ADD COLUMN IF NOT EXISTS linked_lead_name text; -- denormalised: the operator's tenant, not the requester's
265
+
266
+ -- \u2500\u2500 who may operate the helpdesk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
267
+ -- The allow-list is the whole security boundary of the cross-tenant read. A row
268
+ -- here is a GoControl support operator. Nobody writes this from the app; it is
269
+ -- seeded by an admin (see agent-provision / seed script).
270
+ CREATE TABLE IF NOT EXISTS public.plg_support_operators (
271
+ user_id uuid PRIMARY KEY,
272
+ note text,
273
+ created_at timestamptz NOT NULL DEFAULT now()
274
+ );
275
+ ALTER TABLE public.plg_support_operators ENABLE ROW LEVEL SECURITY;
276
+ GRANT SELECT ON public.plg_support_operators TO authenticated;
277
+ GRANT ALL ON public.plg_support_operators TO service_role;
278
+ -- A user may read ONLY their own row \u2014 enough to answer "am I an operator?" in
279
+ -- the UI, and it reveals no one else.
280
+ DROP POLICY IF EXISTS plg_support_operators_self ON public.plg_support_operators;
281
+ CREATE POLICY plg_support_operators_self ON public.plg_support_operators
282
+ FOR SELECT TO authenticated USING (user_id = auth.uid());
283
+
284
+ -- \u2500\u2500 gate \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
285
+ -- Operator = acting IN the helpdesk workspace. The caller's CURRENT tenant
286
+ -- (app.current_tenant_id \u2014 the workspace they have open) must be the one that
287
+ -- runs the helpdesk (support \`console\` facet active) AND they must be a member
288
+ -- of it. So the console is strictly per-WORKSPACE: it works only while you are
289
+ -- inside GoControl, never from a store, whoever you are. The plg_support_operators
290
+ -- allow-list stays as an explicit extra, but it too only counts while you are in
291
+ -- a helpdesk workspace \u2014 it never opens the console from a store.
292
+ CREATE OR REPLACE FUNCTION public.support_is_operator()
293
+ RETURNS boolean
294
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
295
+ AS $fn$
296
+ SELECT EXISTS (
297
+ SELECT 1
298
+ FROM app.tenant_plugins tp
299
+ WHERE tp.plugin_id = 'support' AND tp.facet = 'console' AND tp.status = 'active'
300
+ AND tp.tenant_id = app.current_tenant_id()
301
+ AND (
302
+ EXISTS (
303
+ SELECT 1 FROM app.memberships m
304
+ WHERE m.tenant_id = tp.tenant_id AND m.user_id = auth.uid() AND m.active
305
+ )
306
+ OR EXISTS (SELECT 1 FROM public.plg_support_operators o WHERE o.user_id = auth.uid())
307
+ )
308
+ );
309
+ $fn$;
310
+ GRANT EXECUTE ON FUNCTION public.support_is_operator() TO authenticated;
311
+
312
+ -- A guard the write functions share \u2014 raises if the caller is not an operator.
313
+ CREATE OR REPLACE FUNCTION public.support_console_guard()
314
+ RETURNS void
315
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO ''
316
+ AS $fn$
317
+ BEGIN
318
+ IF NOT public.support_is_operator() THEN
319
+ RAISE EXCEPTION 'not a support operator' USING ERRCODE = '42501';
320
+ END IF;
321
+ END;
322
+ $fn$;
323
+ GRANT EXECUTE ON FUNCTION public.support_console_guard() TO authenticated;
324
+
325
+ -- Resolve the auth user who opened a ticket to a human name: the person record
326
+ -- in that tenant, else the signed-up full name, else the e-mail. So the console
327
+ -- shows "Vini" instead of a uuid.
328
+ CREATE OR REPLACE FUNCTION public._support_user_name(p_user uuid, p_tenant uuid)
329
+ RETURNS text
330
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
331
+ AS $fn$
332
+ SELECT COALESCE(
333
+ (SELECT NULLIF(btrim(p.name), '')
334
+ FROM app.memberships m JOIN public.people p ON p.id = m.person_id
335
+ WHERE m.user_id = p_user AND m.tenant_id = p_tenant LIMIT 1),
336
+ (SELECT NULLIF(btrim(u.raw_user_meta_data->>'full_name'), '') FROM auth.users u WHERE u.id = p_user),
337
+ (SELECT NULLIF(btrim(u.raw_user_meta_data->>'name'), '') FROM auth.users u WHERE u.id = p_user),
338
+ (SELECT u.email FROM auth.users u WHERE u.id = p_user)
339
+ );
340
+ $fn$;
341
+ GRANT EXECUTE ON FUNCTION public._support_user_name(uuid, uuid) TO authenticated;
342
+
343
+ -- \u2500\u2500 read: every tenant's requests, for an operator \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
344
+ CREATE OR REPLACE FUNCTION public.support_console_requests(
345
+ p_statuses text[] DEFAULT NULL,
346
+ p_search text DEFAULT NULL
347
+ )
348
+ RETURNS jsonb
349
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO ''
350
+ AS $fn$
351
+ DECLARE result jsonb;
352
+ BEGIN
353
+ PERFORM public.support_console_guard();
354
+ SELECT COALESCE(jsonb_agg(row_to_json(r)), '[]'::jsonb) INTO result
355
+ FROM (
356
+ SELECT
357
+ q.id, q.number, q.kind, q.subject, q.description, q.status, q.priority,
358
+ q.route, q.created_by, public._support_user_name(q.created_by, q.tenant_id) AS created_by_name,
359
+ q.created_at, q.updated_at,
360
+ q.linked_lead_id, q.linked_lead_name,
361
+ q.tenant_id, t.name AS tenant_name, t.slug AS tenant_slug,
362
+ (SELECT count(*) FROM public.plg_support_request_messages m WHERE m.request_id = q.id) AS message_count
363
+ FROM public.plg_support_requests q
364
+ LEFT JOIN public.tenants t ON t.id = q.tenant_id
365
+ WHERE (p_statuses IS NULL OR q.status = ANY(p_statuses))
366
+ AND (p_search IS NULL OR p_search = ''
367
+ OR q.subject ILIKE '%'||p_search||'%'
368
+ OR q.description ILIKE '%'||p_search||'%'
369
+ OR t.name ILIKE '%'||p_search||'%')
370
+ ORDER BY
371
+ CASE q.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,
372
+ q.updated_at DESC
373
+ ) r;
374
+ RETURN result;
375
+ END;
376
+ $fn$;
377
+ GRANT EXECUTE ON FUNCTION public.support_console_requests(text[], text) TO authenticated;
378
+
379
+ -- \u2500\u2500 read: one request in full \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
380
+ CREATE OR REPLACE FUNCTION public.support_console_detail(p_request_id uuid)
381
+ RETURNS jsonb
382
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO ''
383
+ AS $fn$
384
+ DECLARE result jsonb;
385
+ BEGIN
386
+ PERFORM public.support_console_guard();
387
+ SELECT jsonb_build_object(
388
+ 'request', (SELECT row_to_json(x) FROM (
389
+ SELECT q.*, t.name AS tenant_name, t.slug AS tenant_slug,
390
+ public._support_user_name(q.created_by, q.tenant_id) AS created_by_name
391
+ FROM public.plg_support_requests q
392
+ LEFT JOIN public.tenants t ON t.id = q.tenant_id
393
+ WHERE q.id = p_request_id) x),
394
+ 'messages', (SELECT COALESCE(jsonb_agg(row_to_json(m) ORDER BY m.created_at), '[]'::jsonb)
395
+ FROM public.plg_support_request_messages m WHERE m.request_id = p_request_id),
396
+ 'events', (SELECT COALESCE(jsonb_agg(row_to_json(e) ORDER BY e.created_at), '[]'::jsonb)
397
+ FROM public.plg_support_request_events e WHERE e.request_id = p_request_id),
398
+ 'attachments', (SELECT COALESCE(jsonb_agg(row_to_json(a) ORDER BY a.created_at), '[]'::jsonb)
399
+ FROM public.plg_support_request_attachments a WHERE a.request_id = p_request_id)
400
+ ) INTO result;
401
+ RETURN result;
402
+ END;
403
+ $fn$;
404
+ GRANT EXECUTE ON FUNCTION public.support_console_detail(uuid) TO authenticated;
405
+
406
+ -- Internal: drop one unread notice to the requester, in their own tenant.
407
+ CREATE OR REPLACE FUNCTION public._support_notify(p_request_id uuid, p_kind text)
408
+ RETURNS void
409
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
410
+ AS $fn$
411
+ DECLARE v_tenant uuid; v_user uuid;
412
+ BEGIN
413
+ SELECT tenant_id, created_by INTO v_tenant, v_user
414
+ FROM public.plg_support_requests WHERE id = p_request_id;
415
+ IF v_user IS NULL THEN RETURN; END IF;
416
+ INSERT INTO public.plg_support_notifications (tenant_id, request_id, user_id, kind)
417
+ VALUES (v_tenant, p_request_id, v_user, p_kind);
418
+ END;
419
+ $fn$;
420
+
421
+ -- \u2500\u2500 write: operator changes status (and the client hears about it) \u2500\u2500\u2500\u2500\u2500\u2500\u2500
422
+ CREATE OR REPLACE FUNCTION public.support_console_set_status(
423
+ p_request_id uuid, p_status text, p_note text DEFAULT NULL
424
+ )
425
+ RETURNS jsonb
426
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
427
+ AS $fn$
428
+ DECLARE v_tenant uuid;
429
+ BEGIN
430
+ PERFORM public.support_console_guard();
431
+ IF p_status NOT IN ('received','in_service','waiting_you','in_development','resolved_check','closed','cancelled') THEN
432
+ RAISE EXCEPTION 'invalid status %', p_status USING ERRCODE = '22023';
433
+ END IF;
434
+ SELECT tenant_id INTO v_tenant FROM public.plg_support_requests WHERE id = p_request_id;
435
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'request not found' USING ERRCODE = 'P0002'; END IF;
436
+ UPDATE public.plg_support_requests
437
+ SET status = p_status,
438
+ resolved_at = CASE WHEN p_status IN ('resolved_check','closed') THEN now() ELSE resolved_at END
439
+ WHERE id = p_request_id;
440
+ INSERT INTO public.plg_support_request_events (tenant_id, request_id, type, note)
441
+ VALUES (v_tenant, p_request_id, 'status_changed', p_status);
442
+ PERFORM public._support_notify(p_request_id,
443
+ CASE WHEN p_status IN ('waiting_you','resolved_check') THEN 'waiting_you'
444
+ WHEN p_status IN ('closed') THEN 'resolved' ELSE 'reply' END);
445
+ RETURN public.support_console_detail(p_request_id);
446
+ END;
447
+ $fn$;
448
+ GRANT EXECUTE ON FUNCTION public.support_console_set_status(uuid, text, text) TO authenticated;
449
+
450
+ -- \u2500\u2500 write: operator sets priority \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
451
+ CREATE OR REPLACE FUNCTION public.support_console_set_priority(p_request_id uuid, p_priority text)
452
+ RETURNS jsonb
453
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
454
+ AS $fn$
455
+ BEGIN
456
+ PERFORM public.support_console_guard();
457
+ IF p_priority NOT IN ('low','normal','high','urgent') THEN
458
+ RAISE EXCEPTION 'invalid priority %', p_priority USING ERRCODE = '22023';
459
+ END IF;
460
+ UPDATE public.plg_support_requests SET priority = p_priority WHERE id = p_request_id;
461
+ RETURN public.support_console_detail(p_request_id);
462
+ END;
463
+ $fn$;
464
+ GRANT EXECUTE ON FUNCTION public.support_console_set_priority(uuid, text) TO authenticated;
465
+
466
+ -- \u2500\u2500 write: operator replies to the requester \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
467
+ CREATE OR REPLACE FUNCTION public.support_console_reply(p_request_id uuid, p_body text)
468
+ RETURNS jsonb
469
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
470
+ AS $fn$
471
+ DECLARE v_tenant uuid;
472
+ BEGIN
473
+ PERFORM public.support_console_guard();
474
+ IF p_body IS NULL OR btrim(p_body) = '' THEN RAISE EXCEPTION 'empty message' USING ERRCODE = '22023'; END IF;
475
+ SELECT tenant_id INTO v_tenant FROM public.plg_support_requests WHERE id = p_request_id;
476
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'request not found' USING ERRCODE = 'P0002'; END IF;
477
+ INSERT INTO public.plg_support_request_messages (tenant_id, request_id, author_kind, author_id, body)
478
+ VALUES (v_tenant, p_request_id, 'team', auth.uid(), btrim(p_body));
479
+ PERFORM public._support_notify(p_request_id, 'reply');
480
+ RETURN public.support_console_detail(p_request_id);
481
+ END;
482
+ $fn$;
483
+ GRANT EXECUTE ON FUNCTION public.support_console_reply(uuid, text) TO authenticated;
484
+
485
+ -- \u2500\u2500 write: operator links a CRM lead/client \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
486
+ CREATE OR REPLACE FUNCTION public.support_console_link_lead(
487
+ p_request_id uuid, p_lead_id uuid, p_lead_name text DEFAULT NULL
488
+ )
489
+ RETURNS jsonb
490
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
491
+ AS $fn$
492
+ BEGIN
493
+ PERFORM public.support_console_guard();
494
+ UPDATE public.plg_support_requests
495
+ SET linked_lead_id = p_lead_id, linked_lead_name = p_lead_name
496
+ WHERE id = p_request_id;
497
+ RETURN public.support_console_detail(p_request_id);
498
+ END;
499
+ $fn$;
500
+ GRANT EXECUTE ON FUNCTION public.support_console_link_lead(uuid, uuid, text) TO authenticated;
501
+ `;
502
+ var MIGRATION_002_OPERATOR_ATTACHMENT_READ = `-- plugin-support \u2014 let a helpdesk OPERATOR open a chamado's attachments.
503
+ -- Idempotent / replay-safe.
504
+ --
505
+ -- The bucket's read policy (000_baseline) scopes every object to its owning
506
+ -- tenant: (storage.foldername(name))[1] IN user_tenant_ids(). That is right for
507
+ -- the store, but the GoControl operator is in ANOTHER tenant, so the signed URL
508
+ -- never comes back and the console shows "anexo" instead of the image. This adds
509
+ -- a SECOND, permissive SELECT policy (RLS OR-combines them, so the tenant rule is
510
+ -- untouched) that lets an operator read any support-attachments object \u2014 gated by
511
+ -- the same support_is_operator() that guards the whole console. Read only: upload,
512
+ -- update and delete stay tenant-scoped.
513
+ DROP POLICY IF EXISTS plg_support_attachments_operator_read ON storage.objects;
514
+ CREATE POLICY plg_support_attachments_operator_read ON storage.objects
515
+ FOR SELECT TO authenticated
516
+ USING (bucket_id = 'support-attachments' AND public.support_is_operator());
517
+ `;
518
+ var MIGRATION_003_OPERATOR_ATTACHMENT_READ_FIX = `-- plugin-support \u2014 make the operator attachment policy actually work from Storage.
519
+ -- Idempotent / replay-safe.
520
+ --
521
+ -- 002 gated the operator read on support_is_operator(), which asks
522
+ -- app.current_tenant_id() \u2014 the OPEN workspace. That GUC is set on the normal
523
+ -- data API, but NOT on a Storage sign request, so inside the bucket policy it is
524
+ -- null and the check fails: the operator still got a 400 and saw "anexo".
525
+ --
526
+ -- This swaps to a context-independent test that leans only on auth.uid() (always
527
+ -- present from the JWT): the user is a seeded cross-tenant operator, OR an active
528
+ -- member of a tenant that runs the console (the facet). Same intent as
529
+ -- support_is_operator(), minus the current-workspace scoping that Storage cannot
530
+ -- answer. SECURITY DEFINER so the lookups bypass RLS, like the original.
531
+ CREATE OR REPLACE FUNCTION public.support_is_operator_global()
532
+ RETURNS boolean
533
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
534
+ AS $fn$
535
+ SELECT
536
+ EXISTS (SELECT 1 FROM public.plg_support_operators o WHERE o.user_id = auth.uid())
537
+ OR EXISTS (
538
+ SELECT 1
539
+ FROM app.tenant_plugins tp
540
+ JOIN app.memberships m
541
+ ON m.tenant_id = tp.tenant_id AND m.user_id = auth.uid() AND m.active
542
+ WHERE tp.plugin_id = 'support' AND tp.facet = 'console' AND tp.status = 'active'
543
+ );
544
+ $fn$;
545
+ GRANT EXECUTE ON FUNCTION public.support_is_operator_global() TO authenticated;
546
+
547
+ DROP POLICY IF EXISTS plg_support_attachments_operator_read ON storage.objects;
548
+ CREATE POLICY plg_support_attachments_operator_read ON storage.objects
549
+ FOR SELECT TO authenticated
550
+ USING (bucket_id = 'support-attachments' AND public.support_is_operator_global());
551
+ `;
552
+ var MIGRATIONS = [
553
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
554
+ { id: "001_helpdesk", sql: MIGRATION_001_HELPDESK },
555
+ { id: "002_operator_attachment_read", sql: MIGRATION_002_OPERATOR_ATTACHMENT_READ },
556
+ { id: "003_operator_attachment_read_fix", sql: MIGRATION_003_OPERATOR_ATTACHMENT_READ_FIX }
557
+ ];
558
+ var takeTurnReportFiles = null;
559
+ function normalizeKind(raw) {
560
+ const s = String(raw ?? "").trim().toLowerCase();
561
+ if (["improvement", "melhoria", "sugestao", "sugest\xE3o", "suggestion"].some((k) => s.includes(k))) return "improvement";
562
+ if (["other", "outra", "outras", "outro", "solicitacao", "solicita\xE7\xE3o", "request"].some((k) => s.includes(k))) return "other";
563
+ return "problem";
564
+ }
565
+ function recentScreens(limit = 10) {
566
+ try {
567
+ const stack = useNavHistoryStore.getState().stack;
568
+ return stack.slice(-limit).reverse().map((e) => ({ path: e.path, label: e.label }));
569
+ } catch {
570
+ return [];
571
+ }
572
+ }
573
+ function currentRoute() {
574
+ if (typeof location === "undefined") return null;
575
+ return location.hash || location.pathname || null;
576
+ }
577
+ var supportAITools = [
578
+ {
579
+ id: "support.report-issue",
580
+ name: "reportIssue",
581
+ description: `REGISTERS a support request (problema, melhoria ou outra solicita\xE7\xE3o) for the Fayz team. Use this the moment the user is REPORTING that something is wrong, asking for a change, or raising a request you cannot resolve from the app itself \u2014 "n\xE3o consigo\u2026", "deu erro", "n\xE3o aparece a op\xE7\xE3o de\u2026", "seria bom se\u2026". This is the ONLY correct action for a report: do NOT call any other tool to investigate or "fix" it (no payable/reconciliation/record tools) \u2014 a complaint is not a work order, and guessing at a fix does nothing the user asked for. Before registering: call findSupportRequests to check the issue is not ALREADY open (if it is, do not duplicate \u2014 point the user to that #number). Then use the recent trail you were given to say what the user was doing, confirm the problem in one short question, and offer (do not require) a print. Then call this with a clear description IN THE USER'S OWN WORDS. It returns the request number \u2014 tell the user "registrei a sua solicita\xE7\xE3o, n\xFAmero #N, a equipe vai acompanhar" and STOP. The route and the recent screens are captured automatically; you do not pass them.`,
582
+ icon: "LifeBuoy",
583
+ mode: "persist",
584
+ // No confirmation card: the user already asked for it and the agent confirms
585
+ // in prose. The card would just show the technical params.
586
+ requiresConfirmation: false,
587
+ parameters: {
588
+ type: "object",
589
+ properties: {
590
+ kind: {
591
+ type: "string",
592
+ enum: ["problem", "improvement", "other"],
593
+ description: "problem = algo n\xE3o funcionou; improvement = sugest\xE3o de melhoria; other = treinamento, acesso, dados, comercial. Default problem."
594
+ },
595
+ description: {
596
+ type: "string",
597
+ description: "O que aconteceu, na linguagem do usu\xE1rio: o que ele fazia, o que esperava e o que apareceu."
598
+ },
599
+ subject: {
600
+ type: "string",
601
+ description: "Opcional. Um t\xEDtulo curto para a solicita\xE7\xE3o."
602
+ }
603
+ },
604
+ required: ["description"]
605
+ },
606
+ category: "Suporte",
607
+ suggestions: [
608
+ { label: "Quero reportar um problema", prompt: "Quero reportar um problema" },
609
+ { label: "Quero sugerir uma melhoria", prompt: "Quero sugerir uma melhoria" },
610
+ { label: "Tenho uma d\xFAvida", prompt: "Tenho uma d\xFAvida" },
611
+ { label: "Outra solicita\xE7\xE3o", prompt: "Tenho outra solicita\xE7\xE3o para a equipe" }
612
+ ]
613
+ },
614
+ {
615
+ id: "support.find-requests",
616
+ name: "findSupportRequests",
617
+ description: "Lists the user's OPEN support requests (solicita\xE7\xF5es em aberto). CALL THIS BEFORE registering a problem or improvement, to check whether the same thing is ALREADY reported. If an open request clearly covers the same issue, do NOT open a duplicate: tell the user it is already registered (give the #number and its status) and offer to add a note to it instead. Optional `query` filters by words from the user's complaint.",
618
+ icon: "Search",
619
+ mode: "read",
620
+ parameters: {
621
+ type: "object",
622
+ properties: {
623
+ query: { type: "string", description: "Palavras do que o usu\xE1rio est\xE1 relatando, para achar uma solicita\xE7\xE3o parecida. Opcional." }
624
+ }
625
+ },
626
+ category: "Suporte"
627
+ }
628
+ ];
629
+ async function executeReportIssue(args) {
630
+ const description = String(args.description ?? "").trim();
631
+ if (description.length < 5) {
632
+ return {
633
+ ok: false,
634
+ error: "missing_description",
635
+ message: "Antes de registrar, pergunte ao usu\xE1rio o que aconteceu e descreva com as palavras dele (m\xEDnimo de uma frase)."
636
+ };
637
+ }
638
+ const kind = normalizeKind(args.kind);
639
+ const subject = typeof args.subject === "string" && args.subject.trim() ? args.subject.trim() : null;
640
+ try {
641
+ const res = await createRequest({
642
+ kind,
643
+ description,
644
+ subject,
645
+ route: currentRoute(),
646
+ // What the user was doing travels with the request, so the team reads it
647
+ // without a second round-trip. `activity` is the rich trail (routes +
648
+ // buttons pressed); `recentScreens` stays for tickets opened before it.
649
+ screenSnapshot: { activity: getRecentActivity(10), recentScreens: recentScreens() }
650
+ });
651
+ let attached = 0;
652
+ const files = takeTurnReportFiles ? takeTurnReportFiles() : [];
653
+ for (const file of files) {
654
+ const pending = {
655
+ id: crypto.randomUUID(),
656
+ file,
657
+ name: file.name || "anexo",
658
+ contentType: file.type || "application/octet-stream",
659
+ sizeBytes: file.size
660
+ };
661
+ try {
662
+ await uploadAttachment(res.id, pending);
663
+ attached += 1;
664
+ } catch {
665
+ }
666
+ }
667
+ try {
668
+ void useSupportStore.getState().loadList();
669
+ } catch {
670
+ }
671
+ return {
672
+ ok: true,
673
+ id: res.id,
674
+ number: res.number,
675
+ status: "received",
676
+ attachments: attached,
677
+ ref: { id: res.id, label: `Solicita\xE7\xE3o #${res.number}`, path: `/support/ticket/${res.id}` },
678
+ message: `Solicita\xE7\xE3o #${res.number} registrada${attached ? ` com ${attached} anexo(s)` : ""}. Diga ao usu\xE1rio o n\xFAmero e que a equipe vai acompanhar. N\xC3O chame mais nenhuma ferramenta \u2014 o relato est\xE1 registrado.`
679
+ };
680
+ } catch (error) {
681
+ return {
682
+ ok: false,
683
+ error: "write_failed",
684
+ message: error instanceof Error ? error.message : "N\xE3o consegui registrar a solicita\xE7\xE3o."
685
+ };
686
+ }
687
+ }
688
+ function isOpen(status) {
689
+ return status !== "closed" && status !== "cancelled";
690
+ }
691
+ async function executeFindRequests(args) {
692
+ const query = String(args.query ?? "").trim().toLowerCase();
693
+ const terms = query.split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2);
694
+ let open;
695
+ try {
696
+ open = (await listRequests("mine", "all")).filter((r) => isOpen(r.status));
697
+ } catch (error) {
698
+ return { error: "read_failed", message: error instanceof Error ? error.message : "N\xE3o consegui ler as solicita\xE7\xF5es." };
699
+ }
700
+ const scored = open.map((r) => {
701
+ const hay = `${r.subject ?? ""} ${r.description}`.toLowerCase();
702
+ const score = terms.length ? terms.filter((t) => hay.includes(t)).length : 0;
703
+ return { r, score };
704
+ }).sort((a, b) => b.score - a.score || b.r.number - a.r.number);
705
+ const picked = (terms.length ? scored.filter((s) => s.score > 0) : scored).slice(0, 10);
706
+ return {
707
+ total: picked.length,
708
+ openTotal: open.length,
709
+ requests: picked.map(({ r }) => ({
710
+ number: r.number,
711
+ kind: r.kind,
712
+ subject: r.subject || r.description.slice(0, 80),
713
+ status: r.status,
714
+ createdAt: r.createdAt,
715
+ ref: { id: r.id, label: `Solicita\xE7\xE3o #${r.number}`, path: `/support/ticket/${r.id}` }
716
+ })),
717
+ hint: picked.length ? "If one of these is clearly the SAME issue the user is reporting now, do not open a duplicate \u2014 say it is already registered (#number + status) and offer to add a note." : "Nothing similar is open \u2014 go ahead and register the new request."
718
+ };
719
+ }
720
+ var registered = false;
721
+ function registerSupportAITools() {
722
+ if (registered || typeof document === "undefined") return;
723
+ registered = true;
724
+ void import('@fayz-ai/admin').then((admin) => {
725
+ takeTurnReportFiles = admin.takeTurnReportFiles;
726
+ admin.registerAIToolHandler("reportIssue", (args) => executeReportIssue(args));
727
+ admin.registerAIToolHandler("findSupportRequests", (args) => executeFindRequests(args));
728
+ }).catch(() => {
729
+ registered = false;
730
+ });
731
+ }
732
+ function go(path) {
733
+ if (typeof window !== "undefined") window.location.hash = path;
734
+ }
735
+ var STATUS_ORDER = [
736
+ "received",
737
+ "in_service",
738
+ "waiting_you",
739
+ "in_development",
740
+ "resolved_check",
741
+ "closed",
742
+ "cancelled"
743
+ ];
744
+ function HelpdeskStatusPanel({ tenantId } = {}) {
745
+ const [requests, setRequests] = React2.useState([]);
746
+ const [loading, setLoading] = React2.useState(true);
747
+ React2.useEffect(() => {
748
+ let alive = true;
749
+ setLoading(true);
750
+ consoleListRequests(void 0, "").then((r) => {
751
+ if (alive) setRequests(tenantId ? r.filter((x) => x.tenantId === tenantId) : r);
752
+ }).catch(() => {
753
+ if (alive) setRequests([]);
754
+ }).finally(() => {
755
+ if (alive) setLoading(false);
756
+ });
757
+ return () => {
758
+ alive = false;
759
+ };
760
+ }, [tenantId]);
761
+ const byStatus = STATUS_ORDER.map((s) => ({ status: s, items: requests.filter((r) => r.status === s) })).filter((g) => g.items.length > 0);
762
+ if (loading) {
763
+ return /* @__PURE__ */ jsx("div", { className: "space-y-2 p-1", children: [0, 1].map((i) => /* @__PURE__ */ jsx(Skeleton, { className: "h-14 w-full" }, i)) });
764
+ }
765
+ if (requests.length === 0) {
766
+ return /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: tenantId ? "Esta loja n\xE3o tem chamados." : "Nenhum chamado por aqui." });
767
+ }
768
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
769
+ !tenantId && /* @__PURE__ */ jsxs("p", { className: "px-1 text-xs text-muted-foreground", children: [
770
+ requests.length,
771
+ " chamado(s) de todas as lojas."
772
+ ] }),
773
+ byStatus.map((g) => /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
774
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-1", children: [
775
+ /* @__PURE__ */ jsx(Badge, { variant: STATUS_TONE[g.status], children: STATUS_LABEL[g.status] }),
776
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: g.items.length })
777
+ ] }),
778
+ g.items.map((r) => /* @__PURE__ */ jsxs(
779
+ "button",
780
+ {
781
+ onClick: () => go(`/support/console/${r.id}`),
782
+ className: "flex w-full flex-col gap-0.5 rounded-lg border bg-card p-2 text-left transition-colors hover:border-primary/40 hover:bg-accent",
783
+ children: [
784
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 text-[11px] text-muted-foreground", children: [
785
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium text-foreground", children: r.tenantName ?? "Loja" }),
786
+ /* @__PURE__ */ jsxs("span", { children: [
787
+ "#",
788
+ r.number
789
+ ] })
790
+ ] }),
791
+ /* @__PURE__ */ jsx("div", { className: "truncate text-xs", children: r.subject || r.description.slice(0, 60) })
792
+ ]
793
+ },
794
+ r.id
795
+ ))
796
+ ] }, g.status))
797
+ ] });
798
+ }
799
+
800
+ // src/index.ts
801
+ var LazySupportCenter = React2.lazy(() => import('./SupportCenter-Z2HEPJGD.js'));
802
+ var LazyHelpdeskConsole = React2.lazy(() => import('./HelpdeskConsole-7W5IAQIU.js'));
803
+ var HelpdeskConsolePage = () => React2.createElement(
804
+ React2.Suspense,
805
+ { fallback: React2.createElement("div", { className: "p-6 text-sm text-muted-foreground" }, "Carregando\u2026") },
806
+ React2.createElement(LazyHelpdeskConsole, null)
807
+ );
808
+ function createSupportPlugin(options) {
809
+ const label = options?.label ?? "Suporte";
810
+ registerTranslations(supportLocales);
811
+ registerSupportAITools();
812
+ const content = {
813
+ faq: options?.faq ?? defaultFaq,
814
+ whatsNew: options?.whatsNew ?? defaultWhatsNew,
815
+ statusComponents: options?.statusComponents ?? defaultStatusComponents
816
+ };
817
+ const SupportPage = () => React2.createElement(
818
+ React2.Suspense,
819
+ { fallback: React2.createElement("div", { className: "p-6 text-sm text-muted-foreground" }, "Carregando\u2026") },
820
+ React2.createElement(LazySupportCenter, { content })
821
+ );
822
+ return {
823
+ id: "support",
824
+ name: label,
825
+ icon: "LifeBuoy",
826
+ version: "0.1.0",
827
+ scope: options?.scope ?? "universal",
828
+ verticalId: options?.verticalId,
829
+ defaultEnabled: true,
830
+ dependencies: [],
831
+ // Support is for everyone, like the V1 support panel ("nasce liberada em
832
+ // todos os perfis") — so the route is open to any authenticated user rather
833
+ // than gated by a deny-by-default feature. Only `support.all` is a declared
834
+ // permission: it unlocks the "Toda a empresa" view, and ships denied (the
835
+ // admin/owner holds it).
836
+ declaredFeatures: [
837
+ { id: "support.all", label: "Solicita\xE7\xF5es de toda a empresa", group: label }
838
+ ],
839
+ // The requester reaches Suporte from the user menu. The OPERATOR console line
840
+ // is gated by the `console` FACET — it shows ONLY in the workspace that runs
841
+ // the helpdesk (GoControl), where the facet is on. In a store (Pertinho,
842
+ // Artorius…) the facet is off, so the line is absent no matter who is logged
843
+ // in. It is a per-TENANT line: you see it when you are IN the GoControl
844
+ // workspace, not because of who you are.
845
+ navigation: [
846
+ {
847
+ id: "support-console",
848
+ section: "main",
849
+ position: 45,
850
+ label: "Central de Chamados",
851
+ route: "/support/console",
852
+ icon: "LifeBuoy",
853
+ // Sit right below "Clientes": land above the line the rail draws next
854
+ // after it (AtendeAI, then the CRM/financial block).
855
+ before: ["/atendeai", "/sales", "/financial"],
856
+ requiresFacet: "console"
857
+ }
858
+ ],
859
+ // Off for every tenant by default; enabled only for the helpdesk workspace
860
+ // with scripts/support-enable-console.mjs. A store that turns on Suporte does
861
+ // NOT get it.
862
+ facets: [
863
+ {
864
+ id: "console",
865
+ label: "Central de Chamados (operador)",
866
+ description: "O console cross-tenant do suporte \u2014 s\xF3 no workspace que opera o helpdesk."
867
+ }
868
+ ],
869
+ // "Modo relato": the assistant registers a request instead of improvising a
870
+ // fix. The executor is wired above (registerSupportAITools); the opener
871
+ // suggestions ride on the tool (the V1 doors).
872
+ aiTools: supportAITools,
873
+ routes: [
874
+ {
875
+ path: "/support",
876
+ component: SupportPage,
877
+ guard: "authenticated"
878
+ },
879
+ {
880
+ // The helpdesk console. Open to any authenticated user as a route; the
881
+ // view calls support_is_operator() and shows "acesso restrito" to a
882
+ // non-operator, and every data call is gated server-side anyway.
883
+ path: "/support/console",
884
+ component: HelpdeskConsolePage,
885
+ guard: "authenticated",
886
+ title: "Central de Chamados"
887
+ }
888
+ ],
889
+ migrations: MIGRATIONS.map((entry) => ({
890
+ id: `support-${entry.id}`,
891
+ version: "0.1.0",
892
+ sql: entry.sql,
893
+ description: "Central de Suporte do Dico: the plg_support_* tables (requests, messages, events, attachments, notifications), their RLS, the per-tenant number trigger and the private support-attachments bucket."
894
+ })),
895
+ locales: supportLocales
896
+ };
897
+ }
898
+
899
+ export { HelpdeskStatusPanel, createSupportPlugin };
900
+ //# sourceMappingURL=index.js.map
901
+ //# sourceMappingURL=index.js.map