@ebowwa/crm 0.1.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 (187) hide show
  1. package/README.md +174 -0
  2. package/dist/cli/commands/activities.d.ts +11 -0
  3. package/dist/cli/commands/activities.d.ts.map +1 -0
  4. package/dist/cli/commands/activities.js +427 -0
  5. package/dist/cli/commands/activities.js.map +1 -0
  6. package/dist/cli/commands/contacts.d.ts +11 -0
  7. package/dist/cli/commands/contacts.d.ts.map +1 -0
  8. package/dist/cli/commands/contacts.js +458 -0
  9. package/dist/cli/commands/contacts.js.map +1 -0
  10. package/dist/cli/commands/deals.d.ts +11 -0
  11. package/dist/cli/commands/deals.d.ts.map +1 -0
  12. package/dist/cli/commands/deals.js +498 -0
  13. package/dist/cli/commands/deals.js.map +1 -0
  14. package/dist/cli/commands/media.d.ts +11 -0
  15. package/dist/cli/commands/media.d.ts.map +1 -0
  16. package/dist/cli/commands/media.js +417 -0
  17. package/dist/cli/commands/media.js.map +1 -0
  18. package/dist/cli/commands/search.d.ts +11 -0
  19. package/dist/cli/commands/search.d.ts.map +1 -0
  20. package/dist/cli/commands/search.js +346 -0
  21. package/dist/cli/commands/search.js.map +1 -0
  22. package/dist/cli/index.d.ts +13 -0
  23. package/dist/cli/index.d.ts.map +1 -0
  24. package/dist/cli/index.js +173 -0
  25. package/dist/cli/index.js.map +1 -0
  26. package/dist/cli/repl.d.ts +15 -0
  27. package/dist/cli/repl.d.ts.map +1 -0
  28. package/dist/cli/repl.js +318 -0
  29. package/dist/cli/repl.js.map +1 -0
  30. package/dist/cli/utils/config.d.ts +91 -0
  31. package/dist/cli/utils/config.d.ts.map +1 -0
  32. package/dist/cli/utils/config.js +212 -0
  33. package/dist/cli/utils/config.js.map +1 -0
  34. package/dist/cli/utils/output.d.ts +136 -0
  35. package/dist/cli/utils/output.d.ts.map +1 -0
  36. package/dist/cli/utils/output.js +323 -0
  37. package/dist/cli/utils/output.js.map +1 -0
  38. package/dist/cli/utils/prompt.d.ts +81 -0
  39. package/dist/cli/utils/prompt.d.ts.map +1 -0
  40. package/dist/cli/utils/prompt.js +341 -0
  41. package/dist/cli/utils/prompt.js.map +1 -0
  42. package/dist/cli.d.ts +3 -0
  43. package/dist/cli.d.ts.map +1 -0
  44. package/dist/cli.js +8 -0
  45. package/dist/cli.js.map +1 -0
  46. package/dist/core/index.d.ts +6 -0
  47. package/dist/core/index.d.ts.map +1 -0
  48. package/dist/core/index.js +32 -0
  49. package/dist/core/index.js.map +1 -0
  50. package/dist/core/schemas.d.ts +3050 -0
  51. package/dist/core/schemas.d.ts.map +1 -0
  52. package/dist/core/schemas.js +667 -0
  53. package/dist/core/schemas.js.map +1 -0
  54. package/dist/core/types.d.ts +597 -0
  55. package/dist/core/types.d.ts.map +1 -0
  56. package/dist/core/types.js +8 -0
  57. package/dist/core/types.js.map +1 -0
  58. package/dist/index.d.ts +7 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +8 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/mcp/index.d.ts +14 -0
  63. package/dist/mcp/index.d.ts.map +1 -0
  64. package/dist/mcp/index.js +11 -0
  65. package/dist/mcp/index.js.map +1 -0
  66. package/dist/mcp/server.d.ts +13 -0
  67. package/dist/mcp/server.d.ts.map +1 -0
  68. package/dist/mcp/server.js +18 -0
  69. package/dist/mcp/server.js.map +1 -0
  70. package/dist/mcp/storage/client.d.ts +109 -0
  71. package/dist/mcp/storage/client.d.ts.map +1 -0
  72. package/dist/mcp/storage/client.js +355 -0
  73. package/dist/mcp/storage/client.js.map +1 -0
  74. package/dist/mcp/storage/index.d.ts +7 -0
  75. package/dist/mcp/storage/index.d.ts.map +1 -0
  76. package/dist/mcp/storage/index.js +6 -0
  77. package/dist/mcp/storage/index.js.map +1 -0
  78. package/dist/mcp/storage/types.d.ts +44 -0
  79. package/dist/mcp/storage/types.d.ts.map +1 -0
  80. package/dist/mcp/storage/types.js +35 -0
  81. package/dist/mcp/storage/types.js.map +1 -0
  82. package/dist/mcp/tools/definitions.d.ts +16 -0
  83. package/dist/mcp/tools/definitions.d.ts.map +1 -0
  84. package/dist/mcp/tools/definitions.js +914 -0
  85. package/dist/mcp/tools/definitions.js.map +1 -0
  86. package/dist/mcp/tools/handlers.d.ts +50 -0
  87. package/dist/mcp/tools/handlers.d.ts.map +1 -0
  88. package/dist/mcp/tools/handlers.js +760 -0
  89. package/dist/mcp/tools/handlers.js.map +1 -0
  90. package/dist/mcp/tools/index.d.ts +7 -0
  91. package/dist/mcp/tools/index.d.ts.map +1 -0
  92. package/dist/mcp/tools/index.js +6 -0
  93. package/dist/mcp/tools/index.js.map +1 -0
  94. package/dist/mcp/tools/types.d.ts +314 -0
  95. package/dist/mcp/tools/types.d.ts.map +1 -0
  96. package/dist/mcp/tools/types.js +5 -0
  97. package/dist/mcp/tools/types.js.map +1 -0
  98. package/dist/mcp/transports/stdio.d.ts +27 -0
  99. package/dist/mcp/transports/stdio.d.ts.map +1 -0
  100. package/dist/mcp/transports/stdio.js +237 -0
  101. package/dist/mcp/transports/stdio.js.map +1 -0
  102. package/dist/telemetry/index.d.ts +58 -0
  103. package/dist/telemetry/index.d.ts.map +1 -0
  104. package/dist/telemetry/index.js +109 -0
  105. package/dist/telemetry/index.js.map +1 -0
  106. package/dist/telemetry/logger.d.ts +116 -0
  107. package/dist/telemetry/logger.d.ts.map +1 -0
  108. package/dist/telemetry/logger.js +256 -0
  109. package/dist/telemetry/logger.js.map +1 -0
  110. package/dist/telemetry/metrics.d.ts +115 -0
  111. package/dist/telemetry/metrics.d.ts.map +1 -0
  112. package/dist/telemetry/metrics.js +292 -0
  113. package/dist/telemetry/metrics.js.map +1 -0
  114. package/dist/telemetry/tracer.d.ts +227 -0
  115. package/dist/telemetry/tracer.d.ts.map +1 -0
  116. package/dist/telemetry/tracer.js +355 -0
  117. package/dist/telemetry/tracer.js.map +1 -0
  118. package/dist/web/app.d.ts +2 -0
  119. package/dist/web/app.d.ts.map +1 -0
  120. package/dist/web/app.js +115 -0
  121. package/dist/web/app.js.map +1 -0
  122. package/dist/web/components/ContactList.d.ts +3 -0
  123. package/dist/web/components/ContactList.d.ts.map +1 -0
  124. package/dist/web/components/ContactList.js +262 -0
  125. package/dist/web/components/ContactList.js.map +1 -0
  126. package/dist/web/components/Dashboard.d.ts +3 -0
  127. package/dist/web/components/Dashboard.d.ts.map +1 -0
  128. package/dist/web/components/Dashboard.js +158 -0
  129. package/dist/web/components/Dashboard.js.map +1 -0
  130. package/dist/web/components/DealPipeline.d.ts +3 -0
  131. package/dist/web/components/DealPipeline.d.ts.map +1 -0
  132. package/dist/web/components/DealPipeline.js +306 -0
  133. package/dist/web/components/DealPipeline.js.map +1 -0
  134. package/dist/web/index.d.ts +2 -0
  135. package/dist/web/index.d.ts.map +1 -0
  136. package/dist/web/index.js +269 -0
  137. package/dist/web/index.js.map +1 -0
  138. package/dist/web/types.d.ts +75 -0
  139. package/dist/web/types.d.ts.map +1 -0
  140. package/dist/web/types.js +3 -0
  141. package/dist/web/types.js.map +1 -0
  142. package/native/index.d.ts +571 -0
  143. package/native/index.js +687 -0
  144. package/package.json +105 -0
  145. package/src/cli/commands/activities.ts +543 -0
  146. package/src/cli/commands/contacts.ts +563 -0
  147. package/src/cli/commands/deals.ts +637 -0
  148. package/src/cli/commands/media.ts +521 -0
  149. package/src/cli/commands/search.ts +426 -0
  150. package/src/cli/index.ts +203 -0
  151. package/src/cli/repl.ts +379 -0
  152. package/src/cli/utils/config.ts +299 -0
  153. package/src/cli/utils/output.ts +386 -0
  154. package/src/cli/utils/prompt.ts +444 -0
  155. package/src/cli.ts +11 -0
  156. package/src/core/index.ts +184 -0
  157. package/src/core/schemas.ts +770 -0
  158. package/src/core/types.ts +969 -0
  159. package/src/index.ts +8 -0
  160. package/src/mcp/index.ts +17 -0
  161. package/src/mcp/server.ts +26 -0
  162. package/src/mcp/storage/client.ts +408 -0
  163. package/src/mcp/storage/index.ts +7 -0
  164. package/src/mcp/storage/types.ts +72 -0
  165. package/src/mcp/tools/definitions.ts +961 -0
  166. package/src/mcp/tools/handlers.ts +805 -0
  167. package/src/mcp/tools/index.ts +7 -0
  168. package/src/mcp/tools/types.ts +390 -0
  169. package/src/mcp/transports/stdio.ts +225 -0
  170. package/src/telemetry/index.ts +131 -0
  171. package/src/telemetry/logger.ts +318 -0
  172. package/src/telemetry/metrics.ts +393 -0
  173. package/src/telemetry/tracer.ts +487 -0
  174. package/src/web/api/activities.ts +41 -0
  175. package/src/web/api/contacts.ts +114 -0
  176. package/src/web/api/deals.ts +108 -0
  177. package/src/web/api/media.ts +98 -0
  178. package/src/web/app.tsx +143 -0
  179. package/src/web/components/ActivityFeed.tsx +195 -0
  180. package/src/web/components/ContactList.tsx +340 -0
  181. package/src/web/components/Dashboard.tsx +214 -0
  182. package/src/web/components/DealPipeline.tsx +405 -0
  183. package/src/web/components/MediaGallery.tsx +334 -0
  184. package/src/web/index.html +14 -0
  185. package/src/web/index.ts +326 -0
  186. package/src/web/styles/main.css +180 -0
  187. package/src/web/types.ts +311 -0
@@ -0,0 +1,334 @@
1
+ import React, { useState, useRef } from 'react';
2
+ import type { Media, APIResponse } from '../types';
3
+
4
+ const mediaTypeIcons: Record<string, string> = {
5
+ 'image': '🖼️',
6
+ 'video': '🎬',
7
+ 'audio': '🎵',
8
+ 'application': '📄',
9
+ };
10
+
11
+ const getMediaType = (mimetype: string): string => {
12
+ return mimetype.split('/')[0];
13
+ };
14
+
15
+ const formatFileSize = (bytes: number): string => {
16
+ if (bytes < 1024) return `${bytes} B`;
17
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
18
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
19
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
20
+ };
21
+
22
+ export default function MediaGallery() {
23
+ const [mediaFiles, setMediaFiles] = useState<Media[]>([]);
24
+ const [loading, setLoading] = useState(false);
25
+ const [uploading, setUploading] = useState(false);
26
+ const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
27
+ const [filter, setFilter] = useState<string>('all');
28
+ const [dragOver, setDragOver] = useState(false);
29
+ const fileInputRef = useRef<HTMLInputElement>(null);
30
+
31
+ const fetchMedia = async () => {
32
+ setLoading(true);
33
+ try {
34
+ // In a real implementation, this would fetch from /api/media
35
+ // For now, we'll use mock data
36
+ setMediaFiles([]);
37
+ } catch (error) {
38
+ console.error('Failed to fetch media:', error);
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ };
43
+
44
+ const handleFileSelect = async (files: FileList | null) => {
45
+ if (!files || files.length === 0) return;
46
+
47
+ setUploading(true);
48
+ try {
49
+ const formData = new FormData();
50
+ formData.append('file', files[0]);
51
+
52
+ const response = await fetch('/api/media', {
53
+ method: 'POST',
54
+ body: formData,
55
+ });
56
+
57
+ const data: APIResponse<Media> = await response.json();
58
+
59
+ if (data.success && data.data) {
60
+ setMediaFiles([data.data, ...mediaFiles]);
61
+ }
62
+ } catch (error) {
63
+ console.error('Failed to upload media:', error);
64
+ } finally {
65
+ setUploading(false);
66
+ }
67
+ };
68
+
69
+ const handleDrop = (e: React.DragEvent) => {
70
+ e.preventDefault();
71
+ setDragOver(false);
72
+ handleFileSelect(e.dataTransfer.files);
73
+ };
74
+
75
+ const handleDragOver = (e: React.DragEvent) => {
76
+ e.preventDefault();
77
+ setDragOver(true);
78
+ };
79
+
80
+ const handleDragLeave = () => {
81
+ setDragOver(false);
82
+ };
83
+
84
+ const filteredMedia = filter === 'all'
85
+ ? mediaFiles
86
+ : mediaFiles.filter(m => getMediaType(m.mimetype) === filter);
87
+
88
+ const renderMediaPreview = (media: Media) => {
89
+ const type = getMediaType(media.mimetype);
90
+
91
+ if (type === 'image') {
92
+ return (
93
+ <img
94
+ src={media.url}
95
+ alt={media.filename}
96
+ className="w-full h-full object-cover"
97
+ />
98
+ );
99
+ }
100
+
101
+ if (type === 'video') {
102
+ return (
103
+ <div className="w-full h-full flex items-center justify-center bg-gray-900">
104
+ <video
105
+ src={media.url}
106
+ className="max-w-full max-h-full"
107
+ controls={false}
108
+ />
109
+ <div className="absolute inset-0 flex items-center justify-center">
110
+ <span className="text-4xl">{mediaTypeIcons.video}</span>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ if (type === 'audio') {
117
+ return (
118
+ <div className="w-full h-full flex items-center justify-center bg-gray-900">
119
+ <span className="text-4xl">{mediaTypeIcons.audio}</span>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ return (
125
+ <div className="w-full h-full flex items-center justify-center bg-gray-900">
126
+ <span className="text-4xl">{mediaTypeIcons.application}</span>
127
+ </div>
128
+ );
129
+ };
130
+
131
+ return (
132
+ <div className="space-y-6">
133
+ {/* Header */}
134
+ <div className="flex items-center justify-between">
135
+ <div>
136
+ <h2 className="text-2xl font-bold">Media Gallery</h2>
137
+ <p className="text-gray-400">{mediaFiles.length} files</p>
138
+ </div>
139
+ <button
140
+ onClick={() => fileInputRef.current?.click()}
141
+ className="crm-btn crm-btn-primary"
142
+ >
143
+ + Upload
144
+ </button>
145
+ </div>
146
+
147
+ {/* Upload Area */}
148
+ <div
149
+ className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
150
+ dragOver
151
+ ? 'border-indigo-500 bg-indigo-500/10'
152
+ : 'border-gray-700 bg-gray-800/50'
153
+ }`}
154
+ onDrop={handleDrop}
155
+ onDragOver={handleDragOver}
156
+ onDragLeave={handleDragLeave}
157
+ onClick={() => fileInputRef.current?.click()}
158
+ >
159
+ <input
160
+ ref={fileInputRef}
161
+ type="file"
162
+ className="hidden"
163
+ onChange={(e) => handleFileSelect(e.target.files)}
164
+ accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
165
+ />
166
+
167
+ {uploading ? (
168
+ <div className="flex items-center justify-center gap-3">
169
+ <div className="crm-spinner" />
170
+ <span>Uploading...</span>
171
+ </div>
172
+ ) : (
173
+ <>
174
+ <div className="text-4xl mb-4">📁</div>
175
+ <p className="text-lg font-medium mb-2">Drop files here or click to upload</p>
176
+ <p className="text-sm text-gray-400">
177
+ Supports images, videos, audio, and documents
178
+ </p>
179
+ </>
180
+ )}
181
+ </div>
182
+
183
+ {/* Filters */}
184
+ <div className="flex gap-2">
185
+ {['all', 'image', 'video', 'audio', 'application'].map(type => (
186
+ <button
187
+ key={type}
188
+ onClick={() => setFilter(type)}
189
+ className={`px-4 py-2 rounded-lg text-sm transition-colors ${
190
+ filter === type
191
+ ? 'bg-indigo-600 text-white'
192
+ : 'bg-gray-700 text-gray-300 hover:bg-gray-600'
193
+ }`}
194
+ >
195
+ {type === 'all' ? 'All' : (
196
+ <span className="flex items-center gap-2">
197
+ <span>{mediaTypeIcons[type] || '📄'}</span>
198
+ <span className="capitalize">{type}</span>
199
+ </span>
200
+ )}
201
+ </button>
202
+ ))}
203
+ </div>
204
+
205
+ {/* Media Grid */}
206
+ {loading ? (
207
+ <div className="flex items-center justify-center h-64">
208
+ <div className="crm-spinner" />
209
+ </div>
210
+ ) : filteredMedia.length === 0 ? (
211
+ <div className="text-center py-12 text-gray-400">
212
+ <div className="text-6xl mb-4">📭</div>
213
+ <p className="text-lg">No media files</p>
214
+ <p className="text-sm mt-2">Upload files to see them here</p>
215
+ </div>
216
+ ) : (
217
+ <div className="media-grid">
218
+ {filteredMedia.map(media => (
219
+ <div
220
+ key={media.id}
221
+ onClick={() => setSelectedMedia(media)}
222
+ className="media-item aspect-square overflow-hidden relative group"
223
+ >
224
+ {renderMediaPreview(media)}
225
+
226
+ {/* Overlay */}
227
+ <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
228
+ <div className="absolute bottom-0 left-0 right-0 p-3">
229
+ <p className="text-sm font-medium truncate">{media.filename}</p>
230
+ <p className="text-xs text-gray-400">{formatFileSize(media.size)}</p>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ ))}
235
+ </div>
236
+ )}
237
+
238
+ {/* Preview Modal */}
239
+ {selectedMedia && (
240
+ <div
241
+ className="crm-modal-backdrop"
242
+ onClick={() => setSelectedMedia(null)}
243
+ >
244
+ <div
245
+ className="bg-gray-800 rounded-xl border border-gray-700 max-w-4xl w-full mx-4 overflow-hidden"
246
+ onClick={(e) => e.stopPropagation()}
247
+ >
248
+ {/* Modal Header */}
249
+ <div className="flex items-center justify-between p-4 border-b border-gray-700">
250
+ <div>
251
+ <h3 className="font-medium">{selectedMedia.filename}</h3>
252
+ <p className="text-sm text-gray-400">
253
+ {formatFileSize(selectedMedia.size)} • {selectedMedia.mimetype}
254
+ </p>
255
+ </div>
256
+ <button
257
+ onClick={() => setSelectedMedia(null)}
258
+ className="text-gray-400 hover:text-white transition-colors"
259
+ >
260
+
261
+ </button>
262
+ </div>
263
+
264
+ {/* Modal Content */}
265
+ <div className="p-4">
266
+ {getMediaType(selectedMedia.mimetype) === 'image' && (
267
+ <img
268
+ src={selectedMedia.url}
269
+ alt={selectedMedia.filename}
270
+ className="max-w-full max-h-[60vh] mx-auto rounded-lg"
271
+ />
272
+ )}
273
+
274
+ {getMediaType(selectedMedia.mimetype) === 'video' && (
275
+ <video
276
+ src={selectedMedia.url}
277
+ controls
278
+ className="max-w-full max-h-[60vh] mx-auto rounded-lg"
279
+ />
280
+ )}
281
+
282
+ {getMediaType(selectedMedia.mimetype) === 'audio' && (
283
+ <audio
284
+ src={selectedMedia.url}
285
+ controls
286
+ className="w-full"
287
+ />
288
+ )}
289
+
290
+ {getMediaType(selectedMedia.mimetype) === 'application' && (
291
+ <div className="text-center py-12">
292
+ <div className="text-6xl mb-4">{mediaTypeIcons.application}</div>
293
+ <p className="text-lg">{selectedMedia.filename}</p>
294
+ <a
295
+ href={selectedMedia.url}
296
+ download
297
+ className="crm-btn crm-btn-primary inline-block mt-4"
298
+ >
299
+ Download
300
+ </a>
301
+ </div>
302
+ )}
303
+ </div>
304
+
305
+ {/* Modal Footer */}
306
+ <div className="flex items-center justify-between p-4 border-t border-gray-700 bg-gray-800/50">
307
+ <div className="text-sm text-gray-400">
308
+ Uploaded: {new Date(selectedMedia.uploadedAt).toLocaleString()}
309
+ </div>
310
+ <div className="flex gap-2">
311
+ <a
312
+ href={selectedMedia.url}
313
+ download
314
+ className="crm-btn crm-btn-secondary"
315
+ >
316
+ Download
317
+ </a>
318
+ <button
319
+ onClick={() => {
320
+ // Delete logic would go here
321
+ setSelectedMedia(null);
322
+ }}
323
+ className="crm-btn crm-btn-danger"
324
+ >
325
+ Delete
326
+ </button>
327
+ </div>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ )}
332
+ </div>
333
+ );
334
+ }
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CRM Dashboard</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="./styles/main.css">
9
+ </head>
10
+ <body class="bg-gray-900 text-gray-100 min-h-screen">
11
+ <div id="root"></div>
12
+ <script type="module" src="./app.tsx"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,326 @@
1
+ import index from "./index.html";
2
+ import type { ServerWebSocket } from "bun";
3
+ import type { Contact, Deal, Activity, Media, WSMessage } from "./types";
4
+ import { CRMStorageClient } from "../mcp/storage/client.js";
5
+
6
+ // Persistent storage client
7
+ let storage: CRMStorageClient | null = null;
8
+
9
+ // Connected WebSocket clients
10
+ const connectedClients: Set<ServerWebSocket> = new Set();
11
+
12
+ /**
13
+ * Initialize storage and return when ready
14
+ */
15
+ async function getStorage(): Promise<CRMStorageClient> {
16
+ if (!storage) {
17
+ storage = new CRMStorageClient({
18
+ path: process.env.CRM_DB_PATH || "./data/crm-web",
19
+ mapSize: 512 * 1024 * 1024, // 512MB
20
+ maxDbs: 20,
21
+ });
22
+ await storage.initialize();
23
+ console.log("CRM storage initialized at", process.env.CRM_DB_PATH || "./data/crm-web");
24
+ }
25
+ return storage;
26
+ }
27
+
28
+ // WebSocket message broadcasting
29
+ function broadcast(message: WSMessage) {
30
+ const payload = JSON.stringify({ ...message, timestamp: new Date().toISOString() });
31
+ for (const client of connectedClients) {
32
+ client.send(payload);
33
+ }
34
+ }
35
+
36
+ // Helper to generate IDs
37
+ function generateId(): string {
38
+ return crypto.randomUUID();
39
+ }
40
+
41
+ // API Handlers
42
+ async function listContacts(request: Request): Promise<Response> {
43
+ const url = new URL(request.url);
44
+ const search = url.searchParams.get('search') || '';
45
+ const status = url.searchParams.get('status') || '';
46
+
47
+ const store = await getStorage();
48
+ let results = store.list('contacts');
49
+
50
+ if (search) {
51
+ const searchLower = search.toLowerCase();
52
+ results = results.filter((c: { name?: string; email?: string; company?: string }) =>
53
+ (c.name?.toLowerCase() ?? '').includes(searchLower) ||
54
+ (c.email?.toLowerCase() ?? '').includes(searchLower) ||
55
+ (c.company?.toLowerCase() ?? '').includes(searchLower)
56
+ );
57
+ }
58
+
59
+ if (status) {
60
+ results = results.filter((c: { status?: string }) => c.status === status);
61
+ }
62
+
63
+ return Response.json({ success: true, data: results });
64
+ }
65
+
66
+ async function createContact(request: Request): Promise<Response> {
67
+ const body = await request.json();
68
+ const store = await getStorage();
69
+
70
+ const contact = await store.insert('contacts', {
71
+ name: body.name,
72
+ email: body.email,
73
+ phone: body.phone,
74
+ company: body.company,
75
+ title: body.title,
76
+ status: body.status || 'lead',
77
+ tags: body.tags || [],
78
+ notes: body.notes,
79
+ });
80
+
81
+ // Create activity
82
+ await store.insert('activities', {
83
+ type: 'contact',
84
+ description: `New contact created: ${contact.name}`,
85
+ contactId: contact.id,
86
+ userId: 'system',
87
+ });
88
+
89
+ broadcast({ type: 'contact_update', payload: contact });
90
+
91
+ return Response.json({ success: true, data: contact });
92
+ }
93
+
94
+ async function getContact(request: Request, params: Record<string, string>): Promise<Response> {
95
+ const store = await getStorage();
96
+ const contact = store.get('contacts', params.id);
97
+ if (!contact) {
98
+ return Response.json({ success: false, error: 'Contact not found' }, { status: 404 });
99
+ }
100
+ return Response.json({ success: true, data: contact });
101
+ }
102
+
103
+ async function updateContact(request: Request, params: Record<string, string>): Promise<Response> {
104
+ const store = await getStorage();
105
+ const existing = store.get('contacts', params.id);
106
+ if (!existing) {
107
+ return Response.json({ success: false, error: 'Contact not found' }, { status: 404 });
108
+ }
109
+
110
+ const body = await request.json();
111
+ const updated = await store.update('contacts', params.id, body);
112
+
113
+ // Create activity
114
+ await store.insert('activities', {
115
+ type: 'contact',
116
+ description: `Contact updated: ${updated.name}`,
117
+ contactId: updated.id,
118
+ userId: 'system',
119
+ });
120
+
121
+ broadcast({ type: 'contact_update', payload: updated });
122
+
123
+ return Response.json({ success: true, data: updated });
124
+ }
125
+
126
+ async function deleteContact(request: Request, params: Record<string, string>): Promise<Response> {
127
+ const store = await getStorage();
128
+ const existing = store.get('contacts', params.id);
129
+ if (!existing) {
130
+ return Response.json({ success: false, error: 'Contact not found' }, { status: 404 });
131
+ }
132
+
133
+ await store.delete('contacts', params.id);
134
+
135
+ broadcast({ type: 'contact_update', payload: { id: params.id, deleted: true } });
136
+
137
+ return Response.json({ success: true });
138
+ }
139
+
140
+ async function listDeals(request: Request): Promise<Response> {
141
+ const url = new URL(request.url);
142
+ const stage = url.searchParams.get('stage') || '';
143
+
144
+ const store = await getStorage();
145
+ let results = store.list('deals');
146
+
147
+ if (stage) {
148
+ results = results.filter((d: { stage?: string }) => d.stage === stage);
149
+ }
150
+
151
+ return Response.json({ success: true, data: results });
152
+ }
153
+
154
+ async function createDeal(request: Request): Promise<Response> {
155
+ const body = await request.json();
156
+ const store = await getStorage();
157
+
158
+ const deal = await store.insert('deals', {
159
+ contactId: body.contactId,
160
+ title: body.title,
161
+ value: body.value,
162
+ currency: body.currency || 'USD',
163
+ stage: body.stage || 'discovery',
164
+ probability: body.probability || 10,
165
+ expectedCloseDate: body.expectedCloseDate,
166
+ description: body.description,
167
+ });
168
+
169
+ // Create activity
170
+ await store.insert('activities', {
171
+ type: 'deal',
172
+ description: `New deal created: ${deal.title}`,
173
+ dealId: deal.id,
174
+ userId: 'system',
175
+ });
176
+
177
+ broadcast({ type: 'deal_update', payload: deal });
178
+
179
+ return Response.json({ success: true, data: deal });
180
+ }
181
+
182
+ async function listActivities(request: Request): Promise<Response> {
183
+ const url = new URL(request.url);
184
+ const limit = parseInt(url.searchParams.get('limit') || '50');
185
+
186
+ const store = await getStorage();
187
+ const activities = store.list('activities', { limit });
188
+
189
+ return Response.json({
190
+ success: true,
191
+ data: activities
192
+ });
193
+ }
194
+
195
+ async function uploadMedia(request: Request): Promise<Response> {
196
+ const formData = await request.formData();
197
+ const file = formData.get('file') as File;
198
+
199
+ if (!file) {
200
+ return Response.json({ success: false, error: 'No file provided' }, { status: 400 });
201
+ }
202
+
203
+ const store = await getStorage();
204
+
205
+ // Store file (in production, this would go to S3, etc.)
206
+ const buffer = await file.arrayBuffer();
207
+ const uploadsDir = './uploads';
208
+ await Bun.write(`${uploadsDir}/${crypto.randomUUID()}-${file.name}`, buffer);
209
+
210
+ const media = await store.insert('media', {
211
+ filename: file.name,
212
+ mimetype: file.type,
213
+ size: file.size,
214
+ url: `/media/${crypto.randomUUID()}`,
215
+ contactId: formData.get('contactId') as string || undefined,
216
+ dealId: formData.get('dealId') as string || undefined,
217
+ uploadedBy: 'system',
218
+ });
219
+
220
+ broadcast({ type: 'media_upload', payload: media });
221
+
222
+ return Response.json({ success: true, data: media });
223
+ }
224
+
225
+ async function getDashboardStats(): Promise<Response> {
226
+ const store = await getStorage();
227
+ const stats = store.getStats();
228
+ const deals = store.list('deals');
229
+ const activities = store.list('activities', { limit: 1000 });
230
+
231
+ const activeDeals = deals.filter((d: { stage?: string }) =>
232
+ !d.stage?.startsWith('closed_')
233
+ ).length;
234
+
235
+ const pipelineValue = deals
236
+ .filter((d: { stage?: string }) => !d.stage?.startsWith('closed_'))
237
+ .reduce((sum: number, d: { value?: number }) => sum + (d.value || 0), 0);
238
+
239
+ const wonThisMonth = deals
240
+ .filter((d: { stage?: string }) => d.stage === 'closed_won')
241
+ .reduce((sum: number, d: { value?: number }) => sum + (d.value || 0), 0);
242
+
243
+ const activitiesToday = activities.filter((a: { timestamp?: string }) =>
244
+ a.timestamp && new Date(a.timestamp).toDateString() === new Date().toDateString()
245
+ ).length;
246
+
247
+ return Response.json({
248
+ success: true,
249
+ data: {
250
+ totalContacts: stats.contacts,
251
+ activeDeals,
252
+ pipelineValue,
253
+ wonThisMonth,
254
+ activitiesToday,
255
+ conversionRate: stats.contacts > 0 ? (activeDeals / stats.contacts) * 100 : 0,
256
+ }
257
+ });
258
+ }
259
+
260
+ // WebSocket handler
261
+ const websocketHandler = {
262
+ open(ws: ServerWebSocket) {
263
+ connectedClients.add(ws);
264
+ ws.send(JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() }));
265
+ console.log('WebSocket client connected');
266
+ },
267
+ message(ws: ServerWebSocket, message: string | Buffer) {
268
+ // Handle incoming WebSocket messages
269
+ try {
270
+ const data = JSON.parse(message.toString());
271
+ console.log('WebSocket message:', data);
272
+ // Echo back or broadcast as needed
273
+ } catch (e) {
274
+ console.error('Invalid WebSocket message:', e);
275
+ }
276
+ },
277
+ close(ws: ServerWebSocket) {
278
+ connectedClients.delete(ws);
279
+ console.log('WebSocket client disconnected');
280
+ },
281
+ };
282
+
283
+ // Server
284
+ const server = Bun.serve({
285
+ port: 3001,
286
+ routes: {
287
+ "/": index,
288
+ "/api/contacts": {
289
+ GET: listContacts,
290
+ POST: createContact,
291
+ },
292
+ "/api/contacts/:id": {
293
+ GET: getContact,
294
+ PUT: updateContact,
295
+ DELETE: deleteContact,
296
+ },
297
+ "/api/deals": {
298
+ GET: listDeals,
299
+ POST: createDeal,
300
+ },
301
+ "/api/activities": {
302
+ GET: listActivities,
303
+ },
304
+ "/api/media": {
305
+ POST: uploadMedia,
306
+ },
307
+ "/api/dashboard/stats": {
308
+ GET: getDashboardStats,
309
+ },
310
+ },
311
+ websocket: websocketHandler,
312
+ development: {
313
+ hmr: true,
314
+ console: true,
315
+ },
316
+ });
317
+
318
+ // Ensure uploads directory exists
319
+ Bun.file('./uploads').exists().then(async (exists) => {
320
+ if (!exists) {
321
+ await Bun.write('./uploads/.gitkeep', '');
322
+ }
323
+ });
324
+
325
+ console.log(`CRM Web Interface running at http://localhost:${server.port}`);
326
+ console.log('WebSocket endpoint: ws://localhost:3001/ws');