@fayz-ai/plugin-conversations 0.8.0 → 0.9.0-next.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.
Files changed (60) hide show
  1. package/dist/ConversationsContext.d.ts +18 -1
  2. package/dist/ConversationsContext.d.ts.map +1 -1
  3. package/dist/ConversationsPage.d.ts +3 -1
  4. package/dist/ConversationsPage.d.ts.map +1 -1
  5. package/dist/data/accents.d.ts +3 -0
  6. package/dist/data/accents.d.ts.map +1 -0
  7. package/dist/data/mock.d.ts +7 -1
  8. package/dist/data/mock.d.ts.map +1 -1
  9. package/dist/data/mock.test.d.ts +2 -0
  10. package/dist/data/mock.test.d.ts.map +1 -0
  11. package/dist/data/supabase.d.ts.map +1 -1
  12. package/dist/data/tables.d.ts +5 -0
  13. package/dist/data/tables.d.ts.map +1 -0
  14. package/dist/data/types.d.ts +2 -1
  15. package/dist/data/types.d.ts.map +1 -1
  16. package/dist/index.d.ts +16 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +667 -73
  19. package/dist/index.js.map +1 -1
  20. package/dist/locales/en.d.ts.map +1 -1
  21. package/dist/locales/index.d.ts.map +1 -1
  22. package/dist/locales/pt-BR.d.ts +2 -0
  23. package/dist/locales/pt-BR.d.ts.map +1 -0
  24. package/dist/migrations/index.d.ts +7 -0
  25. package/dist/migrations/index.d.ts.map +1 -0
  26. package/dist/store.d.ts +2 -1
  27. package/dist/store.d.ts.map +1 -1
  28. package/dist/types.d.ts +18 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/views/ContactPanel.d.ts.map +1 -1
  31. package/dist/views/ConversationList.d.ts.map +1 -1
  32. package/dist/views/InboxView.d.ts.map +1 -1
  33. package/dist/views/MessageThread.d.ts.map +1 -1
  34. package/dist/views/NewConversationModal.d.ts +6 -0
  35. package/dist/views/NewConversationModal.d.ts.map +1 -0
  36. package/package.json +15 -11
  37. package/src/ConversationsContext.tsx +31 -1
  38. package/src/ConversationsPage.tsx +6 -3
  39. package/src/data/accents.ts +12 -0
  40. package/src/data/mock.test.ts +90 -0
  41. package/src/data/mock.ts +131 -12
  42. package/src/data/supabase.ts +69 -7
  43. package/src/data/tables.ts +7 -0
  44. package/src/data/types.ts +2 -0
  45. package/src/index.ts +69 -11
  46. package/src/locales/en.ts +64 -0
  47. package/src/locales/index.ts +2 -0
  48. package/src/locales/pt-BR.ts +68 -0
  49. package/src/migrations/001_conversations.sql +74 -0
  50. package/src/migrations/002_contact_person.sql +22 -0
  51. package/src/migrations/index.ts +108 -0
  52. package/src/store.ts +44 -1
  53. package/src/types.ts +19 -0
  54. package/src/views/ContactPanel.tsx +14 -12
  55. package/src/views/ConversationList.tsx +48 -21
  56. package/src/views/InboxView.tsx +3 -1
  57. package/src/views/MessageThread.tsx +14 -16
  58. package/src/views/NewConversationModal.tsx +204 -0
  59. package/dist/index.cjs +0 -904
  60. package/dist/index.cjs.map +0 -1
@@ -1,10 +1,13 @@
1
1
  import { getSupabaseClientOptional } from '@fayz-ai/core'
2
2
  import type { ConversationsProvider } from './types'
3
+ import { T } from './tables'
4
+ import { CHANNEL_ACCENT_HEX } from './accents'
3
5
  import type {
4
6
  Conversation,
5
7
  Message,
6
8
  ListConversationsQuery,
7
9
  SendMessageInput,
10
+ CreateConversationInput,
8
11
  ConversationStatus,
9
12
  } from '../types'
10
13
 
@@ -34,6 +37,7 @@ function mapConversation(r: Row): Conversation {
34
37
  return {
35
38
  id: String(r.id),
36
39
  contactName: (r.contact_name as string) ?? '',
40
+ contactPersonId: (r.contact_person_id as string | null) ?? undefined,
37
41
  contactHandle: (r.contact_handle as string) ?? '',
38
42
  channel: (r.channel as Conversation['channel']) ?? 'sms',
39
43
  lastMessagePreview: (r.last_message_preview as string) ?? '',
@@ -84,7 +88,7 @@ export function createSupabaseConversationsProvider(
84
88
 
85
89
  return {
86
90
  async listConversations(query?: ListConversationsQuery): Promise<Conversation[]> {
87
- let q = (client().from('conversations') as { select: (s: string) => Row }).select('*')
91
+ let q = (client().from(T.conversations) as { select: (s: string) => Row }).select('*')
88
92
 
89
93
  const tenantId = resolveTenantId()
90
94
  if (tenantId) q = (q as { eq: (c: string, v: string) => Row }).eq('tenant_id', tenantId)
@@ -111,7 +115,7 @@ export function createSupabaseConversationsProvider(
111
115
 
112
116
  async getMessages(conversationId: string): Promise<Message[]> {
113
117
  const selected = (
114
- client().from('conversation_messages') as { select: (s: string) => Row }
118
+ client().from(T.messages) as { select: (s: string) => Row }
115
119
  ).select('*')
116
120
  const filtered = (selected as { eq: (c: string, v: string) => Row }).eq(
117
121
  'conversation_id',
@@ -125,11 +129,69 @@ export function createSupabaseConversationsProvider(
125
129
  return (data ?? []).map(mapMessage)
126
130
  },
127
131
 
132
+ async createConversation(input: CreateConversationInput): Promise<Conversation> {
133
+ const tenantId = resolveTenantId()
134
+ // Insert with a null tenant_id fails the plg_conversations RLS WITH CHECK
135
+ // (tenant_id ∈ user_tenant_ids()) with an opaque Postgres error. When the
136
+ // active tenant hasn't resolved yet (bootstrap race), fail fast with an
137
+ // actionable message the compose modal can surface via toast.
138
+ if (!tenantId) {
139
+ throw new Error('[plugin-conversations] Active tenant not resolved — cannot create conversation. Try again in a moment.')
140
+ }
141
+ const now = new Date().toISOString()
142
+ const firstMessage = input.firstMessage?.trim()
143
+
144
+ const convRow: Row = {
145
+ contact_name: input.contactName.trim(),
146
+ // Only written when the picker resolved a real person — an app that
147
+ // hasn't run migration 002 yet would reject an unknown column, and
148
+ // omitting it keeps the free-text path working there.
149
+ ...(input.contactPersonId ? { contact_person_id: input.contactPersonId } : {}),
150
+ contact_handle: input.contactHandle?.trim() || null,
151
+ channel: input.channel,
152
+ last_message_preview: firstMessage || null,
153
+ last_message_at: now,
154
+ unread_count: 0,
155
+ status: 'open',
156
+ assigned_to: selfAuthor,
157
+ accent: CHANNEL_ACCENT_HEX[input.channel],
158
+ tags: [],
159
+ note: input.note?.trim() || null,
160
+ }
161
+ if (tenantId) convRow.tenant_id = tenantId
162
+
163
+ const { data: created, error } = (await (
164
+ (client().from(T.conversations) as { insert: (r: Row) => Row }).insert(convRow) as {
165
+ select: () => { single: () => Promise<{ data: Row | null; error: unknown }> }
166
+ }
167
+ )
168
+ .select()
169
+ .single()) as { data: Row | null; error: unknown }
170
+ if (error) throw error
171
+ if (!created) throw new Error('Conversation not created')
172
+
173
+ // Seed the thread with the first outbound message when provided.
174
+ if (firstMessage) {
175
+ const msgRow: Row = {
176
+ conversation_id: String(created.id),
177
+ channel: input.channel,
178
+ direction: 'outbound',
179
+ body: firstMessage,
180
+ author: selfAuthor,
181
+ at: now,
182
+ }
183
+ if (tenantId) msgRow.tenant_id = tenantId
184
+ await (client().from(T.messages) as { insert: (r: Row) => Promise<unknown> }).insert(msgRow)
185
+ }
186
+
187
+ return mapConversation(created)
188
+ },
189
+
128
190
  async sendMessage(input: SendMessageInput): Promise<Message> {
129
191
  const tenantId = resolveTenantId()
130
192
 
131
193
  // Resolve the channel from the parent conversation so the message matches.
132
- const convSelected = (client().from('conversations') as { select: (s: string) => Row }).select(
194
+ const convSelected = (client().from(T.conversations) as { select: (s: string) => Row }).select(
133
195
  'channel',
134
196
  )
135
197
  const convFiltered = (convSelected as { eq: (c: string, v: string) => Row }).eq(
@@ -153,7 +215,7 @@ export function createSupabaseConversationsProvider(
153
215
  if (tenantId) row.tenant_id = tenantId
154
216
 
155
217
  const { data: created, error } = (await (
156
- (client().from('conversation_messages') as { insert: (r: Row) => Row }).insert(row) as {
218
+ (client().from(T.messages) as { insert: (r: Row) => Row }).insert(row) as {
157
219
  select: () => { single: () => Promise<{ data: Row | null; error: unknown }> }
158
220
  }
159
221
  )
@@ -163,7 +225,7 @@ export function createSupabaseConversationsProvider(
163
225
 
164
226
  // Roll the parent conversation forward (preview / timestamp / unread / reopen).
165
227
  await (
166
- (client().from('conversations') as {
228
+ (client().from(T.conversations) as {
167
229
  update: (r: Row) => Row
168
230
  }).update({
169
231
  last_message_preview: input.body,
@@ -178,7 +240,7 @@ export function createSupabaseConversationsProvider(
178
240
 
179
241
  async markRead(conversationId: string): Promise<void> {
180
242
  const { error } = (await (
181
- (client().from('conversations') as { update: (r: Row) => Row }).update({
243
+ (client().from(T.conversations) as { update: (r: Row) => Row }).update({
182
244
  unread_count: 0,
183
245
  }) as { eq: (c: string, v: string) => Promise<{ error: unknown }> }
184
246
  ).eq('id', conversationId)) as { error: unknown }
@@ -186,7 +248,7 @@ export function createSupabaseConversationsProvider(
186
248
  },
187
249
 
188
250
  async setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation> {
189
- const updated = (client().from('conversations') as { update: (r: Row) => Row }).update({
251
+ const updated = (client().from(T.conversations) as { update: (r: Row) => Row }).update({
190
252
  status,
191
253
  })
192
254
  const filtered = (updated as { eq: (c: string, v: string) => Row }).eq('id', conversationId)
@@ -0,0 +1,7 @@
1
+ // Physical table names for the conversations plugin (plg_conversations* prefix).
2
+ // Import T and reference T.<key> in the data provider so a rename lands in
3
+ // exactly one place.
4
+ export const T = {
5
+ conversations: 'plg_conversations',
6
+ messages: 'plg_conversation_messages',
7
+ } as const
package/src/data/types.ts CHANGED
@@ -3,12 +3,14 @@ import type {
3
3
  Message,
4
4
  ListConversationsQuery,
5
5
  SendMessageInput,
6
+ CreateConversationInput,
6
7
  ConversationStatus,
7
8
  } from '../types'
8
9
 
9
10
  export interface ConversationsProvider {
10
11
  listConversations(query?: ListConversationsQuery): Promise<Conversation[]>
11
12
  getMessages(conversationId: string): Promise<Message[]>
13
+ createConversation(input: CreateConversationInput): Promise<Conversation>
12
14
  sendMessage(input: SendMessageInput): Promise<Message>
13
15
  markRead(conversationId: string): Promise<void>
14
16
  setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation>
package/src/index.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  import React from 'react'
2
2
  import type { PluginManifest, PluginScope, VerticalId } from '@fayz-ai/core'
3
3
  import { getActiveTenantId, getSupabaseClientOptional, registerTranslations } from '@fayz-ai/core'
4
+ import type { EntityLookup } from '@fayz-ai/saas'
4
5
  import { ConversationsPage } from './ConversationsPage'
6
+ import type { ResolvedConversationsConfig } from './ConversationsContext'
5
7
  import type { ConversationsProvider } from './data/types'
6
8
  import { createMockConversationsProvider } from './data/mock'
7
9
  import { createSupabaseConversationsProvider } from './data/supabase'
8
10
  import { createConversationsStore } from './store'
9
11
  import { conversationsLocales } from './locales'
12
+ import { MIGRATION_001_CONVERSATIONS, MIGRATION_002_CONTACT_PERSON } from './migrations'
10
13
 
11
14
  // ---------------------------------------------------------------------------
12
15
  // @fayz-ai/plugin-conversations — the GoHighLevel "Conversations" equivalent:
13
16
  // one omni-channel inbox (SMS / WhatsApp / Instagram / Email / Web chat).
14
17
  // Universal plugin — reusable by any vertical (beauty, resto, agency…).
15
18
  //
16
- // M1 ships a full mock inbox. Real channel connectors (Twilio, WhatsApp Cloud,
17
- // Meta, IMAP) + Supabase-backed threads land in a later milestone.
19
+ // Supabase-backed (plg_conversations / plg_conversation_messages, tenant-scoped
20
+ // via RLS) when a client is registered; a persisted mock inbox otherwise. Real
21
+ // channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver inbound rows
22
+ // out-of-band; this plugin is the read/compose surface.
18
23
  // ---------------------------------------------------------------------------
19
24
 
20
25
  export interface ConversationsPluginOptions {
@@ -24,16 +29,45 @@ export interface ConversationsPluginOptions {
24
29
  scope?: PluginScope
25
30
  verticalId?: VerticalId
26
31
  dataProvider?: ConversationsProvider
32
+
33
+ /**
34
+ * `people.kind` for contacts created from the compose modal's picker. Each
35
+ * vertical names its people differently (beauty 'client', agency 'lead'),
36
+ * hence a knob rather than a hardcoded value. Default: 'contact'.
37
+ */
38
+ contactKind?: string
39
+ /**
40
+ * Per-vertical extension table linked by `person_id` (e.g. 'clients'). Pools
41
+ * without it live off `public.people` alone — absent is not an error.
42
+ */
43
+ contactExtensionTable?: string
44
+ /** Custom search source for the picker. Defaults to the person archetype lookup. */
45
+ contactLookup?: EntityLookup
27
46
  }
28
47
 
29
48
  function createSafeProvider(): ConversationsProvider {
30
- // Real data when a Supabase client is registered (reads/writes the
31
- // `conversations` + `conversation_messages` tables, tenant-scoped via the
32
- // active-org context); falls back to the mock inbox when no backend is wired.
33
- if (getSupabaseClientOptional()) {
34
- return createSupabaseConversationsProvider({ tenantId: () => getActiveTenantId() })
49
+ // Real data when a Supabase client is registered; mock inbox otherwise.
50
+ // Resolution is LAZY (per call): createConversationsPlugin runs at module
51
+ // scope in the app config, BEFORE the Supabase client is registered an
52
+ // eager check here would capture the mock forever even on live backends.
53
+ let real: ConversationsProvider | null = null
54
+ let mock: ConversationsProvider | null = null
55
+ const resolve = (): ConversationsProvider => {
56
+ if (getSupabaseClientOptional()) {
57
+ real ??= createSupabaseConversationsProvider({ tenantId: () => getActiveTenantId() })
58
+ return real
59
+ }
60
+ mock ??= createMockConversationsProvider({ tenantId: () => getActiveTenantId() })
61
+ return mock
62
+ }
63
+ return {
64
+ listConversations: (...a) => resolve().listConversations(...a),
65
+ getMessages: (...a) => resolve().getMessages(...a),
66
+ sendMessage: (...a) => resolve().sendMessage(...a),
67
+ markRead: (...a) => resolve().markRead(...a),
68
+ setStatus: (...a) => resolve().setStatus(...a),
69
+ createConversation: (...a) => resolve().createConversation(...a),
35
70
  }
36
- return createMockConversationsProvider()
37
71
  }
38
72
 
39
73
  export function createConversationsPlugin(options?: ConversationsPluginOptions): PluginManifest {
@@ -41,8 +75,14 @@ export function createConversationsPlugin(options?: ConversationsPluginOptions):
41
75
  const provider = options?.dataProvider ?? createSafeProvider()
42
76
  const store = createConversationsStore(provider)
43
77
 
78
+ const config: ResolvedConversationsConfig = {
79
+ contactKind: options?.contactKind ?? 'contact',
80
+ contactExtensionTable: options?.contactExtensionTable,
81
+ contactLookup: options?.contactLookup,
82
+ }
83
+
44
84
  const PageComponent: React.ComponentType<unknown> = () =>
45
- React.createElement(ConversationsPage, { store })
85
+ React.createElement(ConversationsPage, { store, config })
46
86
  PageComponent.displayName = 'ConversationsPage'
47
87
 
48
88
  return {
@@ -55,6 +95,10 @@ export function createConversationsPlugin(options?: ConversationsPluginOptions):
55
95
  defaultEnabled: true,
56
96
  dependencies: [],
57
97
  declaredFeatures: [{ id: 'conversations', label: 'Conversations', group: 'Engage' }],
98
+ // Recurring monthly quota — counts conversation threads created this month.
99
+ declaredLimits: [
100
+ { key: 'conversations_month', label: 'Conversations this month', table: 'plg_conversations', period: 'month' },
101
+ ],
58
102
  navigation: [
59
103
  {
60
104
  section: options?.navSection ?? 'main',
@@ -119,13 +163,27 @@ export function createConversationsPlugin(options?: ConversationsPluginOptions):
119
163
  permission: { feature: 'conversations', action: 'create' as const },
120
164
  },
121
165
  ],
166
+ migrations: [
167
+ {
168
+ id: 'conversations-001-base-tables',
169
+ version: '1.0.0',
170
+ sql: MIGRATION_001_CONVERSATIONS,
171
+ description: 'Create plg_conversations and plg_conversation_messages (tenant-scoped RLS)',
172
+ },
173
+ {
174
+ id: 'conversations-002-contact-person',
175
+ version: '1.1.0',
176
+ sql: MIGRATION_002_CONTACT_PERSON,
177
+ description: 'Link threads to public.people via contact_person_id (nullable, ON DELETE SET NULL)',
178
+ },
179
+ ],
122
180
  locales: conversationsLocales,
123
181
  }
124
182
  }
125
183
 
126
184
  export type { ConversationsProvider } from './data/types'
127
- export type { Conversation, Message, Channel } from './types'
128
- export { createMockConversationsProvider } from './data/mock'
185
+ export type { Conversation, Message, Channel, CreateConversationInput } from './types'
186
+ export { createMockConversationsProvider, type MockConversationsConfig } from './data/mock'
129
187
  export {
130
188
  createSupabaseConversationsProvider,
131
189
  type SupabaseConversationsConfig,
package/src/locales/en.ts CHANGED
@@ -1,4 +1,68 @@
1
1
  export const en: Record<string, string> = {
2
2
  'conversations.title': 'Conversations',
3
3
  'conversations.subtitle': 'Unified inbox across every channel',
4
+
5
+ // Conversation list
6
+ 'conversations.list.search': 'Search conversations',
7
+ 'conversations.list.loading': 'Loading…',
8
+ 'conversations.list.empty': 'No conversations',
9
+ 'conversations.list.new': 'New conversation',
10
+
11
+ // Channel filters
12
+ 'conversations.filter.all': 'All',
13
+ 'conversations.filter.whatsapp': 'WhatsApp',
14
+ 'conversations.filter.sms': 'SMS',
15
+ 'conversations.filter.instagram': 'Instagram',
16
+ 'conversations.filter.email': 'Email',
17
+ 'conversations.filter.webchat': 'Web',
18
+
19
+ // Status labels
20
+ 'conversations.status.open': 'Open',
21
+ 'conversations.status.snoozed': 'Snoozed',
22
+ 'conversations.status.closed': 'Closed',
23
+
24
+ // Empty state
25
+ 'conversations.empty.select': 'Select a conversation to start chatting',
26
+
27
+ // Thread
28
+ 'conversations.thread.back': 'Back to conversations',
29
+ 'conversations.thread.snooze': 'Snooze',
30
+ 'conversations.thread.close': 'Close',
31
+ 'conversations.thread.details': 'Toggle contact details',
32
+ 'conversations.thread.empty': 'No messages yet',
33
+ 'conversations.thread.reply': 'Reply via {{channel}}…',
34
+ 'conversations.thread.send': 'Send',
35
+
36
+ // Contact panel
37
+ 'conversations.contact.details': 'Details',
38
+ 'conversations.contact.closeDetails': 'Close details',
39
+ 'conversations.contact.channel': 'Channel',
40
+ 'conversations.contact.status': 'Status',
41
+ 'conversations.contact.assignedTo': 'Assigned to',
42
+ 'conversations.contact.location': 'Location',
43
+ 'conversations.contact.tags': 'Tags',
44
+ 'conversations.contact.note': 'Note',
45
+ 'conversations.contact.linkedRecords': 'Linked records',
46
+ 'conversations.contact.noLinkedRecords': 'No linked records yet.',
47
+
48
+ // New-conversation modal
49
+ 'conversations.new.title': 'New conversation',
50
+ 'conversations.new.channel': 'Channel',
51
+ 'conversations.new.contactName': 'Contact name',
52
+ 'conversations.new.contactNamePlaceholder': 'e.g. Jane Doe',
53
+ 'conversations.new.handle': 'Phone / handle / email',
54
+ 'conversations.new.handlePlaceholder': '+1 555 000 0000',
55
+ // The handle field only surfaces when the picked contact has nothing usable
56
+ // for the active channel — otherwise it is derived and shown on the chip.
57
+ 'conversations.new.addHandle': 'Add {label}',
58
+ 'conversations.new.handleLabel.phone': 'phone',
59
+ 'conversations.new.handleLabel.email': 'email',
60
+ 'conversations.new.handleLabel.instagram': 'Instagram handle',
61
+ 'conversations.new.handleLabel.webchat': 'web chat id',
62
+ 'conversations.new.firstMessage': 'First message',
63
+ 'conversations.new.firstMessagePlaceholder': 'Write the first message (optional)…',
64
+ 'conversations.new.cancel': 'Cancel',
65
+ 'conversations.new.create': 'Start conversation',
66
+ 'conversations.new.creating': 'Starting…',
67
+ 'conversations.new.createFailed': 'Could not create the conversation',
4
68
  }
@@ -1,5 +1,7 @@
1
1
  import { en } from './en'
2
+ import { ptBR } from './pt-BR'
2
3
 
3
4
  export const conversationsLocales: Record<string, Record<string, string>> = {
4
5
  en,
6
+ 'pt-BR': ptBR,
5
7
  }
@@ -0,0 +1,68 @@
1
+ export const ptBR: Record<string, string> = {
2
+ 'conversations.title': 'Conversas',
3
+ 'conversations.subtitle': 'Caixa de entrada unificada de todos os canais',
4
+
5
+ // Lista de conversas
6
+ 'conversations.list.search': 'Buscar conversas',
7
+ 'conversations.list.loading': 'Carregando…',
8
+ 'conversations.list.empty': 'Nenhuma conversa',
9
+ 'conversations.list.new': 'Nova conversa',
10
+
11
+ // Filtros de canal
12
+ 'conversations.filter.all': 'Todas',
13
+ 'conversations.filter.whatsapp': 'WhatsApp',
14
+ 'conversations.filter.sms': 'SMS',
15
+ 'conversations.filter.instagram': 'Instagram',
16
+ 'conversations.filter.email': 'E-mail',
17
+ 'conversations.filter.webchat': 'Web',
18
+
19
+ // Rótulos de status
20
+ 'conversations.status.open': 'Aberta',
21
+ 'conversations.status.snoozed': 'Adiada',
22
+ 'conversations.status.closed': 'Encerrada',
23
+
24
+ // Estado vazio
25
+ 'conversations.empty.select': 'Selecione uma conversa para começar a conversar',
26
+
27
+ // Thread
28
+ 'conversations.thread.back': 'Voltar às conversas',
29
+ 'conversations.thread.snooze': 'Adiar',
30
+ 'conversations.thread.close': 'Encerrar',
31
+ 'conversations.thread.details': 'Alternar detalhes do contato',
32
+ 'conversations.thread.empty': 'Nenhuma mensagem ainda',
33
+ 'conversations.thread.reply': 'Responder via {{channel}}…',
34
+ 'conversations.thread.send': 'Enviar',
35
+
36
+ // Painel do contato
37
+ 'conversations.contact.details': 'Detalhes',
38
+ 'conversations.contact.closeDetails': 'Fechar detalhes',
39
+ 'conversations.contact.channel': 'Canal',
40
+ 'conversations.contact.status': 'Status',
41
+ 'conversations.contact.assignedTo': 'Responsável',
42
+ 'conversations.contact.location': 'Localização',
43
+ 'conversations.contact.tags': 'Etiquetas',
44
+ 'conversations.contact.note': 'Nota',
45
+ 'conversations.contact.linkedRecords': 'Registros vinculados',
46
+ 'conversations.contact.noLinkedRecords': 'Nenhum registro vinculado ainda.',
47
+
48
+ // Modal de nova conversa
49
+ 'conversations.new.title': 'Nova conversa',
50
+ 'conversations.new.channel': 'Canal',
51
+ 'conversations.new.contactName': 'Nome do contato',
52
+ 'conversations.new.contactNamePlaceholder': 'ex.: Maria Silva',
53
+ 'conversations.new.handle': 'Telefone / usuário / e-mail',
54
+ 'conversations.new.handlePlaceholder': '+55 11 99999-0000',
55
+ // O campo de handle só aparece quando o contato escolhido não tem o dado do
56
+ // canal ativo — caso contrário ele é derivado e mostrado no chip.
57
+ 'conversations.new.addHandle': 'Adicionar {label}',
58
+ 'conversations.new.handleLabel.phone': 'telefone',
59
+ 'conversations.new.handleLabel.email': 'e-mail',
60
+ 'conversations.new.handleLabel.instagram': '@ do Instagram',
61
+ 'conversations.new.handleLabel.webchat': 'id do chat',
62
+ 'conversations.new.firstMessage': 'Primeira mensagem',
63
+ 'conversations.new.firstMessagePlaceholder': 'Escreva a primeira mensagem (opcional)…',
64
+ 'conversations.new.cancel': 'Cancelar',
65
+ 'conversations.new.create': 'Iniciar conversa',
66
+ 'conversations.new.creating': 'Iniciando…',
67
+ 'conversations.new.createFailed': 'Não foi possível criar a conversa',
68
+ }
@@ -0,0 +1,74 @@
1
+ -- ============================================================================
2
+ -- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /
3
+ -- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.
4
+ -- §1 plg_conversations — one thread per contact+channel
5
+ -- §2 plg_conversation_messages — inbound/outbound messages within a thread
6
+ -- §3 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs
7
+ --
8
+ -- Column names mirror exactly what supabase.ts's mapConversation / mapMessage
9
+ -- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver
10
+ -- inbound rows here out-of-band; the provider is the read/compose surface.
11
+ -- Idempotent + safe to re-run.
12
+ -- ============================================================================
13
+
14
+ -- §1 — conversations (threads)
15
+ CREATE TABLE IF NOT EXISTS public.plg_conversations (
16
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
17
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
18
+ contact_name text NOT NULL,
19
+ contact_handle text,
20
+ channel text NOT NULL
21
+ CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
22
+ last_message_preview text,
23
+ last_message_at timestamptz DEFAULT now(),
24
+ unread_count int DEFAULT 0,
25
+ status text DEFAULT 'open'
26
+ CHECK (status IN ('open', 'snoozed', 'closed')),
27
+ assigned_to text,
28
+ accent text,
29
+ tags text[],
30
+ location text,
31
+ note text,
32
+ created_at timestamptz NOT NULL DEFAULT now(),
33
+ updated_at timestamptz NOT NULL DEFAULT now()
34
+ );
35
+ ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
36
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);
37
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);
38
+
39
+ -- §2 — messages
40
+ CREATE TABLE IF NOT EXISTS public.plg_conversation_messages (
41
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
42
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
43
+ conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,
44
+ channel text
45
+ CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
46
+ direction text
47
+ CHECK (direction IN ('inbound', 'outbound')),
48
+ body text NOT NULL,
49
+ author text,
50
+ at timestamptz DEFAULT now()
51
+ );
52
+ ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
53
+ CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);
54
+
55
+ -- §3 — RLS: authenticated tenant CRUD (the inbox reads/writes here)
56
+ DROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;
57
+ DROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;
58
+ DROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;
59
+ DROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;
60
+ CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
61
+ CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
62
+ CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
63
+ CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
64
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;
65
+
66
+ DROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;
67
+ DROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;
68
+ DROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;
69
+ DROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;
70
+ CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
71
+ CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
72
+ CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
73
+ CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
74
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;
@@ -0,0 +1,22 @@
1
+ -- ============================================================================
2
+ -- plugin-conversations 002: link a thread to a REAL person record.
3
+ --
4
+ -- The compose modal used to take a free-text name + handle, so a conversation
5
+ -- with "Maria" had nothing to do with the Maria in the agenda, the CRM or the
6
+ -- financial module. The shared ContactPicker (find-or-create over
7
+ -- public.people) now resolves a person, and this column stores that link.
8
+ --
9
+ -- Nullable on purpose, in both directions of time:
10
+ -- • rows created before this migration keep working (name/handle only);
11
+ -- • an inbound message from an unknown number still opens a thread with no
12
+ -- person attached — the contact panel can offer "create contact" later.
13
+ -- ON DELETE SET NULL: deleting a person must never take their history with it.
14
+ -- Idempotent + safe to re-run.
15
+ -- ============================================================================
16
+
17
+ ALTER TABLE public.plg_conversations
18
+ ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
21
+ ON public.plg_conversations(tenant_id, contact_person_id)
22
+ WHERE contact_person_id IS NOT NULL;
@@ -0,0 +1,108 @@
1
+ // AUTO-GENERATED from 001_conversations.sql, 002_contact_person.sql — regenerate with scripts/embed-migrations.mjs
2
+ // SQL files are the source of truth; this inline copy lets the manifest declare
3
+ // migrations as data. Do not edit by hand — run the embed script instead.
4
+
5
+ export const MIGRATION_001_CONVERSATIONS = `-- ============================================================================
6
+ -- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /
7
+ -- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.
8
+ -- §1 plg_conversations — one thread per contact+channel
9
+ -- §2 plg_conversation_messages — inbound/outbound messages within a thread
10
+ -- §3 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs
11
+ --
12
+ -- Column names mirror exactly what supabase.ts's mapConversation / mapMessage
13
+ -- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver
14
+ -- inbound rows here out-of-band; the provider is the read/compose surface.
15
+ -- Idempotent + safe to re-run.
16
+ -- ============================================================================
17
+
18
+ -- §1 — conversations (threads)
19
+ CREATE TABLE IF NOT EXISTS public.plg_conversations (
20
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
22
+ contact_name text NOT NULL,
23
+ contact_handle text,
24
+ channel text NOT NULL
25
+ CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
26
+ last_message_preview text,
27
+ last_message_at timestamptz DEFAULT now(),
28
+ unread_count int DEFAULT 0,
29
+ status text DEFAULT 'open'
30
+ CHECK (status IN ('open', 'snoozed', 'closed')),
31
+ assigned_to text,
32
+ accent text,
33
+ tags text[],
34
+ location text,
35
+ note text,
36
+ created_at timestamptz NOT NULL DEFAULT now(),
37
+ updated_at timestamptz NOT NULL DEFAULT now()
38
+ );
39
+ ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
40
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);
41
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);
42
+
43
+ -- §2 — messages
44
+ CREATE TABLE IF NOT EXISTS public.plg_conversation_messages (
45
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
46
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
47
+ conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,
48
+ channel text
49
+ CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
50
+ direction text
51
+ CHECK (direction IN ('inbound', 'outbound')),
52
+ body text NOT NULL,
53
+ author text,
54
+ at timestamptz DEFAULT now()
55
+ );
56
+ ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
57
+ CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);
58
+
59
+ -- §3 — RLS: authenticated tenant CRUD (the inbox reads/writes here)
60
+ DROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;
61
+ DROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;
62
+ DROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;
63
+ DROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;
64
+ CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
65
+ CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
66
+ CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
67
+ CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
68
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;
69
+
70
+ DROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;
71
+ DROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;
72
+ DROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;
73
+ DROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;
74
+ CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
75
+ CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
76
+ CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
77
+ CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
78
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;
79
+ `
80
+
81
+ export const MIGRATION_002_CONTACT_PERSON = `-- ============================================================================
82
+ -- plugin-conversations 002: link a thread to a REAL person record.
83
+ --
84
+ -- The compose modal used to take a free-text name + handle, so a conversation
85
+ -- with "Maria" had nothing to do with the Maria in the agenda, the CRM or the
86
+ -- financial module. The shared ContactPicker (find-or-create over
87
+ -- public.people) now resolves a person, and this column stores that link.
88
+ --
89
+ -- Nullable on purpose, in both directions of time:
90
+ -- • rows created before this migration keep working (name/handle only);
91
+ -- • an inbound message from an unknown number still opens a thread with no
92
+ -- person attached — the contact panel can offer "create contact" later.
93
+ -- ON DELETE SET NULL: deleting a person must never take their history with it.
94
+ -- Idempotent + safe to re-run.
95
+ -- ============================================================================
96
+
97
+ ALTER TABLE public.plg_conversations
98
+ ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
101
+ ON public.plg_conversations(tenant_id, contact_person_id)
102
+ WHERE contact_person_id IS NOT NULL;
103
+ `
104
+
105
+ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
106
+ { id: "001_conversations", sql: MIGRATION_001_CONVERSATIONS },
107
+ { id: "002_contact_person", sql: MIGRATION_002_CONTACT_PERSON },
108
+ ]