@fayz-ai/plugin-conversations 0.2.4 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConversationsContext.d.ts +18 -1
- package/dist/ConversationsContext.d.ts.map +1 -1
- package/dist/ConversationsPage.d.ts +3 -1
- package/dist/ConversationsPage.d.ts.map +1 -1
- package/dist/data/accents.d.ts +3 -0
- package/dist/data/accents.d.ts.map +1 -0
- package/dist/data/mock.d.ts +7 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/mock.test.d.ts +2 -0
- package/dist/data/mock.test.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +5 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/types.d.ts +2 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +664 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +667 -73
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/index.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/store.d.ts +2 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/ContactPanel.d.ts.map +1 -1
- package/dist/views/ConversationList.d.ts.map +1 -1
- package/dist/views/InboxView.d.ts.map +1 -1
- package/dist/views/MessageThread.d.ts.map +1 -1
- package/dist/views/NewConversationModal.d.ts +6 -0
- package/dist/views/NewConversationModal.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/ConversationsContext.tsx +31 -1
- package/src/ConversationsPage.tsx +6 -3
- package/src/data/accents.ts +12 -0
- package/src/data/mock.test.ts +90 -0
- package/src/data/mock.ts +131 -12
- package/src/data/supabase.ts +69 -7
- package/src/data/tables.ts +7 -0
- package/src/data/types.ts +2 -0
- package/src/index.ts +69 -11
- package/src/locales/en.ts +64 -0
- package/src/locales/index.ts +2 -0
- package/src/locales/pt-BR.ts +68 -0
- package/src/migrations/001_conversations.sql +74 -0
- package/src/migrations/002_contact_person.sql +22 -0
- package/src/migrations/index.ts +108 -0
- package/src/store.ts +44 -1
- package/src/types.ts +19 -0
- package/src/views/ContactPanel.tsx +14 -12
- package/src/views/ConversationList.tsx +48 -21
- package/src/views/InboxView.tsx +3 -1
- package/src/views/MessageThread.tsx +14 -16
- package/src/views/NewConversationModal.tsx +204 -0
package/src/data/supabase.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|
package/src/locales/index.ts
CHANGED
|
@@ -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
|
+
]
|