@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.
- 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.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 +15 -11
- 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/dist/index.cjs +0 -904
- package/dist/index.cjs.map +0 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const MIGRATION_001_CONVERSATIONS = "-- ============================================================================\n-- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /\n-- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.\n-- \u00A71 plg_conversations \u2014 one thread per contact+channel\n-- \u00A72 plg_conversation_messages \u2014 inbound/outbound messages within a thread\n-- \u00A73 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs\n--\n-- Column names mirror exactly what supabase.ts's mapConversation / mapMessage\n-- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver\n-- inbound rows here out-of-band; the provider is the read/compose surface.\n-- Idempotent + safe to re-run.\n-- ============================================================================\n\n-- \u00A71 \u2014 conversations (threads)\nCREATE TABLE IF NOT EXISTS public.plg_conversations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n contact_name text NOT NULL,\n contact_handle text,\n channel text NOT NULL\n CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),\n last_message_preview text,\n last_message_at timestamptz DEFAULT now(),\n unread_count int DEFAULT 0,\n status text DEFAULT 'open'\n CHECK (status IN ('open', 'snoozed', 'closed')),\n assigned_to text,\n accent text,\n tags text[],\n location text,\n note text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);\n\n-- \u00A72 \u2014 messages\nCREATE TABLE IF NOT EXISTS public.plg_conversation_messages (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,\n channel text\n CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),\n direction text\n CHECK (direction IN ('inbound', 'outbound')),\n body text NOT NULL,\n author text,\n at timestamptz DEFAULT now()\n);\nALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);\n\n-- \u00A73 \u2014 RLS: authenticated tenant CRUD (the inbox reads/writes here)\nDROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;\nDROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;\nDROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;\nDROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;\nCREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;\n\nDROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;\nDROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;\nDROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;\nDROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;\nCREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nCREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;\n";
|
|
2
|
+
export declare const MIGRATION_002_CONTACT_PERSON = "-- ============================================================================\n-- plugin-conversations 002: link a thread to a REAL person record.\n--\n-- The compose modal used to take a free-text name + handle, so a conversation\n-- with \"Maria\" had nothing to do with the Maria in the agenda, the CRM or the\n-- financial module. The shared ContactPicker (find-or-create over\n-- public.people) now resolves a person, and this column stores that link.\n--\n-- Nullable on purpose, in both directions of time:\n-- \u2022 rows created before this migration keep working (name/handle only);\n-- \u2022 an inbound message from an unknown number still opens a thread with no\n-- person attached \u2014 the contact panel can offer \"create contact\" later.\n-- ON DELETE SET NULL: deleting a person must never take their history with it.\n-- Idempotent + safe to re-run.\n-- ============================================================================\n\nALTER TABLE public.plg_conversations\n ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_plg_conversations_person\n ON public.plg_conversations(tenant_id, contact_person_id)\n WHERE contact_person_id IS NOT NULL;\n";
|
|
3
|
+
export declare const MIGRATIONS: Array<{
|
|
4
|
+
id: string;
|
|
5
|
+
sql: string;
|
|
6
|
+
}>;
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,2BAA2B,gyJA0EvC,CAAA;AAED,eAAO,MAAM,4BAA4B,mvCAsBxC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAGzD,CAAA"}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type StoreApi } from 'zustand/vanilla';
|
|
2
2
|
import type { ConversationsProvider } from './data/types';
|
|
3
|
-
import type { Conversation, Message, Channel, ConversationStatus } from './types';
|
|
3
|
+
import type { Conversation, Message, Channel, ConversationStatus, CreateConversationInput } from './types';
|
|
4
4
|
export interface ConversationsUIState {
|
|
5
5
|
conversations: Conversation[];
|
|
6
6
|
messages: Message[];
|
|
@@ -14,6 +14,7 @@ export interface ConversationsUIState {
|
|
|
14
14
|
deselect(): void;
|
|
15
15
|
setChannelFilter(channel: Channel | 'all'): Promise<void>;
|
|
16
16
|
setSearch(search: string): Promise<void>;
|
|
17
|
+
create(input: CreateConversationInput): Promise<Conversation>;
|
|
17
18
|
send(body: string): Promise<void>;
|
|
18
19
|
setStatus(status: ConversationStatus): Promise<void>;
|
|
19
20
|
}
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AACzD,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AACzD,OAAO,KAAK,EACV,YAAY,EACZ,OAAO,EACP,OAAO,EACP,kBAAkB,EAClB,uBAAuB,EACxB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,YAAY,EAAE,CAAA;IAC7B,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,aAAa,EAAE,OAAO,GAAG,KAAK,CAAA;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,OAAO,CAAA;IAEhB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjC,QAAQ,IAAI,IAAI,CAAA;IAChB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzD,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxC,MAAM,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IAC7D,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjC,SAAS,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACrD;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,qBAAqB,GAC9B,QAAQ,CAAC,oBAAoB,CAAC,CAwGhC"}
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export type MessageDirection = 'inbound' | 'outbound';
|
|
|
4
4
|
export interface Conversation {
|
|
5
5
|
id: string;
|
|
6
6
|
contactName: string;
|
|
7
|
+
/**
|
|
8
|
+
* `public.people` id when the thread is tied to a real contact record (the
|
|
9
|
+
* compose modal resolves one via the shared ContactPicker). Absent for legacy
|
|
10
|
+
* threads and for inbound messages from an unknown handle.
|
|
11
|
+
*/
|
|
12
|
+
contactPersonId?: string;
|
|
7
13
|
/** Phone, @handle, or email depending on channel */
|
|
8
14
|
contactHandle: string;
|
|
9
15
|
channel: Channel;
|
|
@@ -36,5 +42,17 @@ export interface SendMessageInput {
|
|
|
36
42
|
conversationId: string;
|
|
37
43
|
body: string;
|
|
38
44
|
}
|
|
45
|
+
export interface CreateConversationInput {
|
|
46
|
+
contactName: string;
|
|
47
|
+
/** `public.people` id, when the contact was resolved/created by the picker. */
|
|
48
|
+
contactPersonId?: string;
|
|
49
|
+
/** Phone, @handle, or email depending on channel. */
|
|
50
|
+
contactHandle?: string;
|
|
51
|
+
channel: Channel;
|
|
52
|
+
/** Optional first outbound message; stamps preview + last_message_at. */
|
|
53
|
+
firstMessage?: string;
|
|
54
|
+
/** Optional free-text note surfaced in the contact panel. */
|
|
55
|
+
note?: string;
|
|
56
|
+
}
|
|
39
57
|
export declare const CHANNEL_LABELS: Record<Channel, string>;
|
|
40
58
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,OAAO,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,GAAG,SAAS,CAAA;AAE5E,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAA;AAE9D,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,CAAA;AAErD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,CAAA;IACnB,oDAAoD;IACpD,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,OAAO,CAAA;IAChB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,kBAAkB,CAAA;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,EAAE,CAAA;IAEd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,gBAAgB,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,OAAO,GAAG,KAAK,CAAA;IACzB,MAAM,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAA;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,MAAM,CAAA;IACtB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAMlD,CAAA"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,OAAO,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,GAAG,SAAS,CAAA;AAE5E,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAA;AAE9D,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,CAAA;AAErD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,oDAAoD;IACpD,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,OAAO,CAAA;IAChB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,kBAAkB,CAAA;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,EAAE,CAAA;IAEd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,gBAAgB,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,OAAO,GAAG,KAAK,CAAA;IACzB,MAAM,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAA;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,MAAM,CAAA;IACtB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAA;IACnB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,qDAAqD;IACrD,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAMlD,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContactPanel.d.ts","sourceRoot":"","sources":["../../src/views/ContactPanel.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"ContactPanel.d.ts","sourceRoot":"","sources":["../../src/views/ContactPanel.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAKzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA;AAkB5C,wBAAgB,YAAY,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE;IAC5D,OAAO,EAAE,YAAY,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,qBAqEA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationList.d.ts","sourceRoot":"","sources":["../../src/views/ConversationList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"ConversationList.d.ts","sourceRoot":"","sources":["../../src/views/ConversationList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAYzB,wBAAgB,gBAAgB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBA0HrE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InboxView.d.ts","sourceRoot":"","sources":["../../src/views/InboxView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"InboxView.d.ts","sourceRoot":"","sources":["../../src/views/InboxView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAUzB,wBAAgB,SAAS,sBAqDxB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessageThread.d.ts","sourceRoot":"","sources":["../../src/views/MessageThread.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"MessageThread.d.ts","sourceRoot":"","sources":["../../src/views/MessageThread.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAMzB,OAAO,KAAK,EAAE,YAAY,EAAW,MAAM,UAAU,CAAA;AA2BrD,wBAAgB,aAAa,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE;IACvF,QAAQ,EAAE,YAAY,CAAA;IACtB,aAAa,EAAE,MAAM,IAAI,CAAA;IACzB,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,qBA+HA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NewConversationModal.d.ts","sourceRoot":"","sources":["../../src/views/NewConversationModal.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAgCzB,wBAAgB,oBAAoB,CAAC,EACnC,IAAI,EACJ,YAAY,GACb,EAAE;IACD,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;CACtC,qBAqKA"}
|
package/package.json
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
"fayz": {
|
|
4
4
|
"status": "preview"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.9.0-next.0",
|
|
7
7
|
"description": "[experimental] Fayz SDK — unified conversations / omni-channel inbox plugin",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"main": "./dist/index.js",
|
|
10
11
|
"module": "./dist/index.js",
|
|
11
12
|
"types": "./dist/index.d.ts",
|
|
12
13
|
"exports": {
|
|
13
14
|
".": {
|
|
14
15
|
"source": "./src/index.ts",
|
|
15
16
|
"types": "./dist/index.d.ts",
|
|
16
|
-
"import": "./dist/index.js"
|
|
17
|
-
"require": "./dist/index.cjs"
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
@@ -23,21 +23,24 @@
|
|
|
23
23
|
],
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"react": "^18.0.0 || ^19.0.0",
|
|
26
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
26
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
27
|
+
"zustand": "^4.5.0",
|
|
28
|
+
"lucide-react": ">=0.400.0 <1.0.0"
|
|
27
29
|
},
|
|
28
30
|
"dependencies": {
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"@fayz-ai/
|
|
32
|
-
"@fayz-ai/ui": "^0.8.0",
|
|
33
|
-
"@fayz-ai/saas": "^0.8.0"
|
|
31
|
+
"@fayz-ai/core": "^0.9.0-next.0",
|
|
32
|
+
"@fayz-ai/ui": "^0.9.0-next.0",
|
|
33
|
+
"@fayz-ai/saas": "^0.9.0-next.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/react": "^18.3.0",
|
|
37
37
|
"react": "^18.3.0",
|
|
38
38
|
"tsup": "^8.2.0",
|
|
39
39
|
"typescript": "^5.5.0",
|
|
40
|
-
"@types/react-dom": "^18.3.0"
|
|
40
|
+
"@types/react-dom": "^18.3.0",
|
|
41
|
+
"vitest": "^2.1.9",
|
|
42
|
+
"zustand": "^4.5.0",
|
|
43
|
+
"lucide-react": "^0.400.0"
|
|
41
44
|
},
|
|
42
45
|
"license": "MIT",
|
|
43
46
|
"keywords": [
|
|
@@ -52,6 +55,7 @@
|
|
|
52
55
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
|
|
53
56
|
"dev": "tsup --watch",
|
|
54
57
|
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run",
|
|
55
59
|
"clean": "rm -rf dist"
|
|
56
60
|
}
|
|
57
61
|
}
|
|
@@ -1,17 +1,43 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { useStore, type StoreApi } from 'zustand'
|
|
3
|
+
import type { EntityLookup } from '@fayz-ai/saas'
|
|
3
4
|
import type { ConversationsUIState } from './store'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* App-tunable knobs the inbox UI reads. Kept tiny on purpose: everything here
|
|
8
|
+
* describes the APP's data shape (which people are "contacts" in this vertical),
|
|
9
|
+
* never product policy.
|
|
10
|
+
*/
|
|
11
|
+
export interface ResolvedConversationsConfig {
|
|
12
|
+
/** `people.kind` used when the compose modal creates a contact. */
|
|
13
|
+
contactKind: string
|
|
14
|
+
/** Per-vertical extension table linked by `person_id` (skipped when absent). */
|
|
15
|
+
contactExtensionTable?: string
|
|
16
|
+
/** Search source for the contact picker. Defaults to the person archetype. */
|
|
17
|
+
contactLookup?: EntityLookup
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_CONVERSATIONS_CONFIG: ResolvedConversationsConfig = {
|
|
21
|
+
contactKind: 'contact',
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
const StoreContext = React.createContext<StoreApi<ConversationsUIState> | null>(null)
|
|
25
|
+
const ConfigContext = React.createContext<ResolvedConversationsConfig>(DEFAULT_CONVERSATIONS_CONFIG)
|
|
6
26
|
|
|
7
27
|
export function ConversationsContextProvider({
|
|
8
28
|
store,
|
|
29
|
+
config = DEFAULT_CONVERSATIONS_CONFIG,
|
|
9
30
|
children,
|
|
10
31
|
}: {
|
|
11
32
|
store: StoreApi<ConversationsUIState>
|
|
33
|
+
config?: ResolvedConversationsConfig
|
|
12
34
|
children?: React.ReactNode
|
|
13
35
|
}) {
|
|
14
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
<StoreContext.Provider value={store}>
|
|
38
|
+
<ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
|
|
39
|
+
</StoreContext.Provider>
|
|
40
|
+
)
|
|
15
41
|
}
|
|
16
42
|
|
|
17
43
|
export function useConversationsStore<T>(selector: (state: ConversationsUIState) => T): T {
|
|
@@ -19,3 +45,7 @@ export function useConversationsStore<T>(selector: (state: ConversationsUIState)
|
|
|
19
45
|
if (!store) throw new Error('useConversationsStore must be used within ConversationsPage')
|
|
20
46
|
return useStore(store, selector)
|
|
21
47
|
}
|
|
48
|
+
|
|
49
|
+
export function useConversationsConfig(): ResolvedConversationsConfig {
|
|
50
|
+
return React.useContext(ConfigContext)
|
|
51
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import type { StoreApi } from 'zustand/vanilla'
|
|
3
|
-
import { ConversationsContextProvider } from './ConversationsContext'
|
|
3
|
+
import { ConversationsContextProvider, type ResolvedConversationsConfig } from './ConversationsContext'
|
|
4
4
|
import type { ConversationsUIState } from './store'
|
|
5
5
|
import { InboxView } from './views/InboxView'
|
|
6
6
|
|
|
7
|
-
export function ConversationsPage({ store }: {
|
|
7
|
+
export function ConversationsPage({ store, config }: {
|
|
8
|
+
store: StoreApi<ConversationsUIState>
|
|
9
|
+
config?: ResolvedConversationsConfig
|
|
10
|
+
}) {
|
|
8
11
|
React.useEffect(() => {
|
|
9
12
|
void store.getState().load()
|
|
10
13
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
11
14
|
}, [])
|
|
12
15
|
|
|
13
16
|
return (
|
|
14
|
-
<ConversationsContextProvider store={store}>
|
|
17
|
+
<ConversationsContextProvider store={store} config={config}>
|
|
15
18
|
<InboxView />
|
|
16
19
|
</ConversationsContextProvider>
|
|
17
20
|
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Channel } from '../types'
|
|
2
|
+
|
|
3
|
+
// Solid brand hex per channel — mirrors CHANNEL_ACCENT in ../channel.ts but
|
|
4
|
+
// React-free so the data providers can stamp a sensible avatar accent on new
|
|
5
|
+
// conversations without importing the icon layer.
|
|
6
|
+
export const CHANNEL_ACCENT_HEX: Record<Channel, string> = {
|
|
7
|
+
whatsapp: '#22c55e',
|
|
8
|
+
sms: '#6366f1',
|
|
9
|
+
instagram: '#ec4899',
|
|
10
|
+
email: '#0ea5e9',
|
|
11
|
+
webchat: '#f59e0b',
|
|
12
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { createMockConversationsProvider } from './mock'
|
|
3
|
+
|
|
4
|
+
// Minimal localStorage shim so we can exercise the persistence path (the mock
|
|
5
|
+
// guards on `typeof window`, degrading to pure memory when absent).
|
|
6
|
+
function installLocalStorage(): void {
|
|
7
|
+
const store = new Map<string, string>()
|
|
8
|
+
const localStorage = {
|
|
9
|
+
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
|
10
|
+
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
|
11
|
+
removeItem: (k: string) => void store.delete(k),
|
|
12
|
+
clear: () => void store.clear(),
|
|
13
|
+
}
|
|
14
|
+
;(globalThis as unknown as { window: unknown }).window = { localStorage }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function uninstallLocalStorage(): void {
|
|
18
|
+
delete (globalThis as unknown as { window?: unknown }).window
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('plugin-conversations · mock provider', () => {
|
|
22
|
+
it('ships a typed seed', async () => {
|
|
23
|
+
const provider = createMockConversationsProvider()
|
|
24
|
+
const list = await provider.listConversations()
|
|
25
|
+
expect(list.length).toBeGreaterThan(0)
|
|
26
|
+
expect(list[0].contactName).toBeTruthy()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('createConversation → sendMessage → list persists within the session', async () => {
|
|
30
|
+
const provider = createMockConversationsProvider({ tenantId: 'test-tenant' })
|
|
31
|
+
|
|
32
|
+
const created = await provider.createConversation({
|
|
33
|
+
channel: 'sms',
|
|
34
|
+
contactName: 'Ada Lovelace',
|
|
35
|
+
contactHandle: '+1 555 010 0000',
|
|
36
|
+
firstMessage: 'Hello there',
|
|
37
|
+
})
|
|
38
|
+
expect(created.id).toBeTruthy()
|
|
39
|
+
expect(created.channel).toBe('sms')
|
|
40
|
+
expect(created.contactName).toBe('Ada Lovelace')
|
|
41
|
+
expect(created.lastMessagePreview).toBe('Hello there')
|
|
42
|
+
|
|
43
|
+
// The seeded first message is present.
|
|
44
|
+
const seededMsgs = await provider.getMessages(created.id)
|
|
45
|
+
expect(seededMsgs).toHaveLength(1)
|
|
46
|
+
expect(seededMsgs[0].direction).toBe('outbound')
|
|
47
|
+
expect(seededMsgs[0].body).toBe('Hello there')
|
|
48
|
+
|
|
49
|
+
// A follow-up reply rolls the thread + preview forward.
|
|
50
|
+
const reply = await provider.sendMessage({ conversationId: created.id, body: 'How are you?' })
|
|
51
|
+
expect(reply.direction).toBe('outbound')
|
|
52
|
+
|
|
53
|
+
const msgs = await provider.getMessages(created.id)
|
|
54
|
+
expect(msgs.map((m) => m.body)).toEqual(['Hello there', 'How are you?'])
|
|
55
|
+
|
|
56
|
+
// The new conversation surfaces in list() with the latest preview.
|
|
57
|
+
const list = await provider.listConversations()
|
|
58
|
+
const found = list.find((c) => c.id === created.id)
|
|
59
|
+
expect(found).toBeDefined()
|
|
60
|
+
expect(found!.lastMessagePreview).toBe('How are you?')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('persists across provider instances via localStorage (reload survives)', async () => {
|
|
64
|
+
installLocalStorage()
|
|
65
|
+
try {
|
|
66
|
+
const first = createMockConversationsProvider({ tenantId: 'reload-tenant' })
|
|
67
|
+
const created = await first.createConversation({
|
|
68
|
+
channel: 'whatsapp',
|
|
69
|
+
contactName: 'Grace Hopper',
|
|
70
|
+
firstMessage: 'Compiling',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Simulate a reload: a brand-new provider for the same tenant.
|
|
74
|
+
const second = createMockConversationsProvider({ tenantId: 'reload-tenant' })
|
|
75
|
+
const list = await second.listConversations()
|
|
76
|
+
const found = list.find((c) => c.id === created.id)
|
|
77
|
+
expect(found).toBeDefined()
|
|
78
|
+
expect(found!.contactName).toBe('Grace Hopper')
|
|
79
|
+
|
|
80
|
+
const msgs = await second.getMessages(created.id)
|
|
81
|
+
expect(msgs.map((m) => m.body)).toContain('Compiling')
|
|
82
|
+
} finally {
|
|
83
|
+
uninstallLocalStorage()
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Guard so the shim never leaks into other suites if this file is imported elsewhere.
|
|
89
|
+
beforeEach(() => uninstallLocalStorage())
|
|
90
|
+
afterEach(() => uninstallLocalStorage())
|
package/src/data/mock.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ConversationsProvider } from './types'
|
|
2
|
+
import { CHANNEL_ACCENT_HEX } from './accents'
|
|
2
3
|
import type {
|
|
3
4
|
Conversation,
|
|
4
5
|
Message,
|
|
5
6
|
ListConversationsQuery,
|
|
6
7
|
SendMessageInput,
|
|
8
|
+
CreateConversationInput,
|
|
7
9
|
ConversationStatus,
|
|
8
10
|
} from '../types'
|
|
9
11
|
|
|
@@ -82,13 +84,90 @@ function msg(
|
|
|
82
84
|
return { id, conversationId, channel, direction, body, author, at }
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
export
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
export interface MockConversationsConfig {
|
|
88
|
+
/** Tenant id (value or getter) used to namespace the localStorage snapshot. */
|
|
89
|
+
tenantId?: string | (() => string | undefined)
|
|
90
|
+
/** Display name stamped as the author of outbound messages. */
|
|
91
|
+
selfAuthor?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Snapshot {
|
|
95
|
+
conversations: Conversation[]
|
|
96
|
+
messages: Message[]
|
|
97
|
+
counter: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// In-memory mock inbox with best-effort localStorage persistence, so a browser
|
|
102
|
+
// reload keeps whatever the demo created/sent within the session. The store is
|
|
103
|
+
// namespaced per tenant; SSR / no-window environments degrade to pure memory.
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
export function createMockConversationsProvider(
|
|
106
|
+
config?: MockConversationsConfig,
|
|
107
|
+
): ConversationsProvider {
|
|
108
|
+
const selfAuthor = config?.selfAuthor ?? 'You'
|
|
109
|
+
|
|
110
|
+
function resolveTenant(): string {
|
|
111
|
+
const raw = typeof config?.tenantId === 'function' ? config.tenantId() : config?.tenantId
|
|
112
|
+
return raw || 'default'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const storageKey = () => `saas:mock:conversations:${resolveTenant()}`
|
|
116
|
+
|
|
117
|
+
function hasStorage(): boolean {
|
|
118
|
+
try {
|
|
119
|
+
return typeof window !== 'undefined' && !!window.localStorage
|
|
120
|
+
} catch {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function load(): Snapshot {
|
|
126
|
+
if (hasStorage()) {
|
|
127
|
+
try {
|
|
128
|
+
const raw = window.localStorage.getItem(storageKey())
|
|
129
|
+
if (raw) {
|
|
130
|
+
const parsed = JSON.parse(raw) as Snapshot
|
|
131
|
+
if (Array.isArray(parsed.conversations) && Array.isArray(parsed.messages)) {
|
|
132
|
+
return {
|
|
133
|
+
conversations: parsed.conversations,
|
|
134
|
+
messages: parsed.messages,
|
|
135
|
+
counter: parsed.counter ?? 100,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Corrupt snapshot — fall through to a fresh seed.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const seeded = seed()
|
|
144
|
+
return { conversations: seeded.conversations, messages: seeded.messages, counter: 100 }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const state = load()
|
|
148
|
+
|
|
149
|
+
function persist(): void {
|
|
150
|
+
if (!hasStorage()) return
|
|
151
|
+
try {
|
|
152
|
+
window.localStorage.setItem(
|
|
153
|
+
storageKey(),
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
conversations: state.conversations,
|
|
156
|
+
messages: state.messages,
|
|
157
|
+
counter: state.counter,
|
|
158
|
+
} satisfies Snapshot),
|
|
159
|
+
)
|
|
160
|
+
} catch {
|
|
161
|
+
// Quota / privacy mode — keep working in memory only.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Persist the initial seed so a first reload is already stable.
|
|
166
|
+
persist()
|
|
88
167
|
|
|
89
168
|
return {
|
|
90
169
|
async listConversations(query?: ListConversationsQuery): Promise<Conversation[]> {
|
|
91
|
-
let list = [...conversations]
|
|
170
|
+
let list = [...state.conversations]
|
|
92
171
|
if (query?.channel && query.channel !== 'all') list = list.filter((c) => c.channel === query.channel)
|
|
93
172
|
if (query?.status && query.status !== 'all') list = list.filter((c) => c.status === query.status)
|
|
94
173
|
if (query?.search) {
|
|
@@ -101,41 +180,81 @@ export function createMockConversationsProvider(): ConversationsProvider {
|
|
|
101
180
|
},
|
|
102
181
|
|
|
103
182
|
async getMessages(conversationId: string): Promise<Message[]> {
|
|
104
|
-
return messages
|
|
183
|
+
return state.messages
|
|
105
184
|
.filter((m) => m.conversationId === conversationId)
|
|
106
185
|
.sort((a, b) => a.at.localeCompare(b.at))
|
|
107
186
|
},
|
|
108
187
|
|
|
188
|
+
async createConversation(input: CreateConversationInput): Promise<Conversation> {
|
|
189
|
+
const now = new Date().toISOString()
|
|
190
|
+
const firstMessage = input.firstMessage?.trim()
|
|
191
|
+
const id = `c${++state.counter}`
|
|
192
|
+
const conversation: Conversation = {
|
|
193
|
+
id,
|
|
194
|
+
contactName: input.contactName.trim(),
|
|
195
|
+
contactPersonId: input.contactPersonId,
|
|
196
|
+
contactHandle: input.contactHandle?.trim() ?? '',
|
|
197
|
+
channel: input.channel,
|
|
198
|
+
lastMessagePreview: firstMessage ?? '',
|
|
199
|
+
lastMessageAt: now,
|
|
200
|
+
unreadCount: 0,
|
|
201
|
+
status: 'open',
|
|
202
|
+
assignedTo: selfAuthor,
|
|
203
|
+
accent: CHANNEL_ACCENT_HEX[input.channel],
|
|
204
|
+
tags: [],
|
|
205
|
+
note: input.note?.trim() || undefined,
|
|
206
|
+
}
|
|
207
|
+
state.conversations.unshift(conversation)
|
|
208
|
+
if (firstMessage) {
|
|
209
|
+
state.messages.push({
|
|
210
|
+
id: `m${++state.counter}`,
|
|
211
|
+
conversationId: id,
|
|
212
|
+
channel: input.channel,
|
|
213
|
+
direction: 'outbound',
|
|
214
|
+
body: firstMessage,
|
|
215
|
+
author: selfAuthor,
|
|
216
|
+
at: now,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
persist()
|
|
220
|
+
return conversation
|
|
221
|
+
},
|
|
222
|
+
|
|
109
223
|
async sendMessage(input: SendMessageInput): Promise<Message> {
|
|
110
|
-
const conv = conversations.find((c) => c.id === input.conversationId)
|
|
224
|
+
const conv = state.conversations.find((c) => c.id === input.conversationId)
|
|
111
225
|
const created: Message = {
|
|
112
|
-
id: `m${++counter}`,
|
|
226
|
+
id: `m${++state.counter}`,
|
|
113
227
|
conversationId: input.conversationId,
|
|
114
228
|
channel: conv?.channel ?? 'sms',
|
|
115
229
|
direction: 'outbound',
|
|
116
230
|
body: input.body,
|
|
117
|
-
author:
|
|
231
|
+
author: selfAuthor,
|
|
118
232
|
at: new Date().toISOString(),
|
|
119
233
|
}
|
|
120
|
-
messages.push(created)
|
|
234
|
+
state.messages.push(created)
|
|
121
235
|
if (conv) {
|
|
122
236
|
conv.lastMessagePreview = input.body
|
|
123
237
|
conv.lastMessageAt = created.at
|
|
124
238
|
conv.unreadCount = 0
|
|
125
239
|
if (conv.status === 'closed') conv.status = 'open'
|
|
126
240
|
}
|
|
241
|
+
persist()
|
|
127
242
|
return created
|
|
128
243
|
},
|
|
129
244
|
|
|
130
245
|
async markRead(conversationId: string): Promise<void> {
|
|
131
|
-
const conv = conversations.find((c) => c.id === conversationId)
|
|
132
|
-
if (conv
|
|
246
|
+
const conv = state.conversations.find((c) => c.id === conversationId)
|
|
247
|
+
if (conv && conv.unreadCount !== 0) {
|
|
248
|
+
conv.unreadCount = 0
|
|
249
|
+
persist()
|
|
250
|
+
}
|
|
133
251
|
},
|
|
134
252
|
|
|
135
253
|
async setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation> {
|
|
136
|
-
const conv = conversations.find((c) => c.id === conversationId)
|
|
254
|
+
const conv = state.conversations.find((c) => c.id === conversationId)
|
|
137
255
|
if (!conv) throw new Error('Conversation not found')
|
|
138
256
|
conv.status = status
|
|
257
|
+
persist()
|
|
139
258
|
return conv
|
|
140
259
|
},
|
|
141
260
|
}
|