@nextsparkjs/theme-crm 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CRM_PLAN.md +343 -0
  2. package/about.md +122 -0
  3. package/config/app.config.ts +185 -0
  4. package/config/billing.config.ts +187 -0
  5. package/config/dashboard.config.ts +372 -0
  6. package/config/dev.config.ts +55 -0
  7. package/config/features.config.ts +336 -0
  8. package/config/flows.config.ts +511 -0
  9. package/config/permissions.config.ts +297 -0
  10. package/config/theme.config.ts +111 -0
  11. package/entities/activities/activities.config.ts +61 -0
  12. package/entities/activities/activities.fields.ts +362 -0
  13. package/entities/activities/activities.service.ts +503 -0
  14. package/entities/activities/activities.types.ts +117 -0
  15. package/entities/activities/messages/en.json +123 -0
  16. package/entities/activities/messages/es.json +123 -0
  17. package/entities/activities/migrations/020_activities_table.sql +123 -0
  18. package/entities/activities/migrations/021_activities_metas.sql +114 -0
  19. package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
  20. package/entities/campaigns/campaigns.config.ts +61 -0
  21. package/entities/campaigns/campaigns.fields.ts +413 -0
  22. package/entities/campaigns/campaigns.service.ts +426 -0
  23. package/entities/campaigns/campaigns.types.ts +124 -0
  24. package/entities/campaigns/messages/en.json +145 -0
  25. package/entities/campaigns/messages/es.json +145 -0
  26. package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
  27. package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
  28. package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
  29. package/entities/companies/companies.config.ts +61 -0
  30. package/entities/companies/companies.fields.ts +429 -0
  31. package/entities/companies/companies.service.ts +566 -0
  32. package/entities/companies/companies.types.ts +125 -0
  33. package/entities/companies/messages/en.json +146 -0
  34. package/entities/companies/messages/es.json +146 -0
  35. package/entities/companies/migrations/001_companies_table.sql +150 -0
  36. package/entities/companies/migrations/002_companies_metas.sql +114 -0
  37. package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
  38. package/entities/contacts/contacts.config.ts +61 -0
  39. package/entities/contacts/contacts.fields.ts +359 -0
  40. package/entities/contacts/contacts.service.ts +509 -0
  41. package/entities/contacts/contacts.types.ts +108 -0
  42. package/entities/contacts/messages/en.json +117 -0
  43. package/entities/contacts/messages/es.json +117 -0
  44. package/entities/contacts/migrations/001_contacts_table.sql +134 -0
  45. package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
  46. package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
  47. package/entities/leads/leads.config.ts +61 -0
  48. package/entities/leads/leads.fields.ts +336 -0
  49. package/entities/leads/leads.service.ts +496 -0
  50. package/entities/leads/leads.types.ts +114 -0
  51. package/entities/leads/messages/en.json +132 -0
  52. package/entities/leads/messages/es.json +132 -0
  53. package/entities/leads/migrations/001_leads_table.sql +150 -0
  54. package/entities/leads/migrations/002_leads_metas.sql +120 -0
  55. package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
  56. package/entities/notes/messages/en.json +114 -0
  57. package/entities/notes/messages/es.json +114 -0
  58. package/entities/notes/migrations/020_notes_table.sql +118 -0
  59. package/entities/notes/migrations/021_notes_metas.sql +114 -0
  60. package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
  61. package/entities/notes/notes.config.ts +61 -0
  62. package/entities/notes/notes.fields.ts +283 -0
  63. package/entities/notes/notes.service.ts +320 -0
  64. package/entities/notes/notes.types.ts +102 -0
  65. package/entities/opportunities/messages/en.json +107 -0
  66. package/entities/opportunities/messages/es.json +107 -0
  67. package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
  68. package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
  69. package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
  70. package/entities/opportunities/opportunities.config.ts +61 -0
  71. package/entities/opportunities/opportunities.fields.ts +416 -0
  72. package/entities/opportunities/opportunities.service.ts +525 -0
  73. package/entities/opportunities/opportunities.types.ts +135 -0
  74. package/entities/pipelines/messages/en.json +115 -0
  75. package/entities/pipelines/messages/es.json +115 -0
  76. package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
  77. package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
  78. package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
  79. package/entities/pipelines/pipelines.config.ts +62 -0
  80. package/entities/pipelines/pipelines.fields.ts +193 -0
  81. package/entities/pipelines/pipelines.service.ts +383 -0
  82. package/entities/pipelines/pipelines.types.ts +78 -0
  83. package/entities/products/messages/en.json +135 -0
  84. package/entities/products/messages/es.json +135 -0
  85. package/entities/products/migrations/001_products_table.sql +117 -0
  86. package/entities/products/migrations/002_products_metas.sql +114 -0
  87. package/entities/products/migrations/003_products_sample_data.sql +247 -0
  88. package/entities/products/products.config.ts +62 -0
  89. package/entities/products/products.fields.ts +361 -0
  90. package/entities/products/products.service.ts +437 -0
  91. package/entities/products/products.types.ts +125 -0
  92. package/lib/crm-constants.ts +77 -0
  93. package/lib/crm-utils.ts +185 -0
  94. package/lib/selectors.ts +333 -0
  95. package/messages/en.json +131 -0
  96. package/messages/es.json +131 -0
  97. package/migrations/999_theme_sample_data.sql +473 -0
  98. package/package.json +18 -0
  99. package/pendings.md +205 -0
  100. package/permissions-matrix.md +216 -0
  101. package/styles/components.css +414 -0
  102. package/styles/crm-theme.css +358 -0
  103. package/styles/globals.css +576 -0
  104. package/styles/variables.css +111 -0
  105. package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
  106. package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
  107. package/templates/dashboard/(main)/activities/page.tsx +297 -0
  108. package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
  109. package/templates/dashboard/(main)/companies/page.tsx +296 -0
  110. package/templates/dashboard/(main)/contacts/page.tsx +347 -0
  111. package/templates/dashboard/(main)/layout.tsx +98 -0
  112. package/templates/dashboard/(main)/leads/page.tsx +335 -0
  113. package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
  114. package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
  115. package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
  116. package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
  117. package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
  118. package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
  119. package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
  120. package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
  121. package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
  122. package/templates/dashboard/(main)/products/create/page.tsx +96 -0
  123. package/templates/dashboard/(main)/products/page.tsx +308 -0
  124. package/templates/shared/ActionButtons.tsx +41 -0
  125. package/templates/shared/CRMDashboard.tsx +519 -0
  126. package/templates/shared/CRMDataTable.tsx +441 -0
  127. package/templates/shared/CRMMetricCard.tsx +76 -0
  128. package/templates/shared/CRMMobileNav.tsx +172 -0
  129. package/templates/shared/CRMSidebar.tsx +346 -0
  130. package/templates/shared/CRMTopBar.tsx +265 -0
  131. package/templates/shared/DealCard.tsx +123 -0
  132. package/templates/shared/EntityCard.tsx +58 -0
  133. package/templates/shared/OpportunityForm.tsx +649 -0
  134. package/templates/shared/PipelineForm.tsx +367 -0
  135. package/templates/shared/PipelineKanban.tsx +194 -0
  136. package/templates/shared/QuickFilters.tsx +47 -0
  137. package/templates/shared/StageColumn.tsx +175 -0
  138. package/templates/shared/StageSelect.tsx +177 -0
  139. package/templates/shared/StagesRepeater.tsx +317 -0
  140. package/templates/shared/index.ts +9 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Leads Service
3
+ *
4
+ * Provides data access methods for leads.
5
+ * Leads is a private entity - users only see leads in their team.
6
+ *
7
+ * All methods require authentication (use RLS with userId filter).
8
+ *
9
+ * @module LeadsService
10
+ */
11
+
12
+ import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
13
+
14
+ // Lead source type
15
+ export type LeadSource = 'website' | 'referral' | 'social' | 'email' | 'event' | 'cold_call' | 'other'
16
+
17
+ // Lead status type
18
+ export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost'
19
+
20
+ // Company size type
21
+ export type CompanySize = 'startup' | 'small' | 'medium' | 'enterprise'
22
+
23
+ // Lead interface
24
+ export interface Lead {
25
+ id: string
26
+ companyName: string
27
+ contactName: string
28
+ email: string
29
+ phone?: string
30
+ website?: string
31
+ source: LeadSource
32
+ status: LeadStatus
33
+ score?: number
34
+ industry?: string
35
+ companySize?: CompanySize
36
+ budget?: number
37
+ assignedTo?: string
38
+ notes?: string
39
+ createdAt: string
40
+ updatedAt: string
41
+ }
42
+
43
+ // List options
44
+ export interface LeadListOptions {
45
+ limit?: number
46
+ offset?: number
47
+ source?: LeadSource
48
+ status?: LeadStatus
49
+ companySize?: CompanySize
50
+ minScore?: number
51
+ assignedTo?: string
52
+ orderBy?: 'companyName' | 'score' | 'createdAt' | 'updatedAt'
53
+ orderDir?: 'asc' | 'desc'
54
+ teamId?: string
55
+ }
56
+
57
+ // List result
58
+ export interface LeadListResult {
59
+ leads: Lead[]
60
+ total: number
61
+ }
62
+
63
+ // Create data
64
+ export interface LeadCreateData {
65
+ companyName: string
66
+ contactName: string
67
+ email: string
68
+ phone?: string
69
+ website?: string
70
+ source: LeadSource
71
+ status?: LeadStatus
72
+ score?: number
73
+ industry?: string
74
+ companySize?: CompanySize
75
+ budget?: number
76
+ assignedTo?: string
77
+ notes?: string
78
+ teamId: string
79
+ }
80
+
81
+ // Update data
82
+ export interface LeadUpdateData {
83
+ companyName?: string
84
+ contactName?: string
85
+ email?: string
86
+ phone?: string
87
+ website?: string
88
+ source?: LeadSource
89
+ status?: LeadStatus
90
+ score?: number
91
+ industry?: string
92
+ companySize?: CompanySize
93
+ budget?: number
94
+ assignedTo?: string
95
+ notes?: string
96
+ }
97
+
98
+ // Database row type
99
+ interface DbLead {
100
+ id: string
101
+ companyName: string
102
+ contactName: string
103
+ email: string
104
+ phone: string | null
105
+ website: string | null
106
+ source: LeadSource
107
+ status: LeadStatus
108
+ score: number | null
109
+ industry: string | null
110
+ companySize: CompanySize | null
111
+ budget: number | null
112
+ assignedTo: string | null
113
+ notes: string | null
114
+ createdAt: string
115
+ updatedAt: string
116
+ }
117
+
118
+ export class LeadsService {
119
+ // ============================================
120
+ // READ METHODS
121
+ // ============================================
122
+
123
+ /**
124
+ * Get a lead by ID
125
+ */
126
+ static async getById(id: string, userId: string): Promise<Lead | null> {
127
+ try {
128
+ if (!id?.trim()) throw new Error('Lead ID is required')
129
+ if (!userId?.trim()) throw new Error('User ID is required')
130
+
131
+ const lead = await queryOneWithRLS<DbLead>(
132
+ `SELECT id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"
133
+ FROM leads WHERE id = $1`,
134
+ [id],
135
+ userId
136
+ )
137
+
138
+ if (!lead) return null
139
+
140
+ return {
141
+ id: lead.id,
142
+ companyName: lead.companyName,
143
+ contactName: lead.contactName,
144
+ email: lead.email,
145
+ phone: lead.phone ?? undefined,
146
+ website: lead.website ?? undefined,
147
+ source: lead.source,
148
+ status: lead.status,
149
+ score: lead.score ?? undefined,
150
+ industry: lead.industry ?? undefined,
151
+ companySize: lead.companySize ?? undefined,
152
+ budget: lead.budget ?? undefined,
153
+ assignedTo: lead.assignedTo ?? undefined,
154
+ notes: lead.notes ?? undefined,
155
+ createdAt: lead.createdAt,
156
+ updatedAt: lead.updatedAt,
157
+ }
158
+ } catch (error) {
159
+ console.error('LeadsService.getById error:', error)
160
+ throw new Error(error instanceof Error ? error.message : 'Failed to fetch lead')
161
+ }
162
+ }
163
+
164
+ /**
165
+ * List leads with pagination and filtering
166
+ */
167
+ static async list(userId: string, options: LeadListOptions = {}): Promise<LeadListResult> {
168
+ try {
169
+ if (!userId?.trim()) throw new Error('User ID is required')
170
+
171
+ const {
172
+ limit = 10,
173
+ offset = 0,
174
+ source,
175
+ status,
176
+ companySize,
177
+ minScore,
178
+ assignedTo,
179
+ orderBy = 'createdAt',
180
+ orderDir = 'desc',
181
+ teamId,
182
+ } = options
183
+
184
+ // Build WHERE clause
185
+ const conditions: string[] = []
186
+ const params: unknown[] = []
187
+ let paramIndex = 1
188
+
189
+ if (source) {
190
+ conditions.push(`source = $${paramIndex++}`)
191
+ params.push(source)
192
+ }
193
+
194
+ if (status) {
195
+ conditions.push(`status = $${paramIndex++}`)
196
+ params.push(status)
197
+ }
198
+
199
+ if (companySize) {
200
+ conditions.push(`"companySize" = $${paramIndex++}`)
201
+ params.push(companySize)
202
+ }
203
+
204
+ if (minScore !== undefined) {
205
+ conditions.push(`score >= $${paramIndex++}`)
206
+ params.push(minScore)
207
+ }
208
+
209
+ if (assignedTo) {
210
+ conditions.push(`"assignedTo" = $${paramIndex++}`)
211
+ params.push(assignedTo)
212
+ }
213
+
214
+ if (teamId) {
215
+ conditions.push(`"teamId" = $${paramIndex++}`)
216
+ params.push(teamId)
217
+ }
218
+
219
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
220
+
221
+ // Validate orderBy
222
+ const validOrderBy = ['companyName', 'score', 'createdAt', 'updatedAt'].includes(orderBy) ? orderBy : 'createdAt'
223
+ const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
224
+ const orderColumnMap: Record<string, string> = {
225
+ companyName: '"companyName"',
226
+ score: 'score',
227
+ createdAt: '"createdAt"',
228
+ updatedAt: '"updatedAt"',
229
+ }
230
+ const orderColumn = orderColumnMap[validOrderBy] || '"createdAt"'
231
+
232
+ // Get total count
233
+ const countResult = await queryWithRLS<{ count: string }>(
234
+ `SELECT COUNT(*)::text as count FROM leads ${whereClause}`,
235
+ params,
236
+ userId
237
+ )
238
+ const total = parseInt(countResult[0]?.count || '0', 10)
239
+
240
+ // Get leads
241
+ params.push(limit, offset)
242
+ const leads = await queryWithRLS<DbLead>(
243
+ `SELECT id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"
244
+ FROM leads ${whereClause}
245
+ ORDER BY ${orderColumn} ${validOrderDir}
246
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
247
+ params,
248
+ userId
249
+ )
250
+
251
+ return {
252
+ leads: leads.map((lead) => ({
253
+ id: lead.id,
254
+ companyName: lead.companyName,
255
+ contactName: lead.contactName,
256
+ email: lead.email,
257
+ phone: lead.phone ?? undefined,
258
+ website: lead.website ?? undefined,
259
+ source: lead.source,
260
+ status: lead.status,
261
+ score: lead.score ?? undefined,
262
+ industry: lead.industry ?? undefined,
263
+ companySize: lead.companySize ?? undefined,
264
+ budget: lead.budget ?? undefined,
265
+ assignedTo: lead.assignedTo ?? undefined,
266
+ notes: lead.notes ?? undefined,
267
+ createdAt: lead.createdAt,
268
+ updatedAt: lead.updatedAt,
269
+ })),
270
+ total,
271
+ }
272
+ } catch (error) {
273
+ console.error('LeadsService.list error:', error)
274
+ throw new Error(error instanceof Error ? error.message : 'Failed to list leads')
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Get leads by status
280
+ */
281
+ static async getByStatus(userId: string, status: LeadStatus, limit = 50): Promise<Lead[]> {
282
+ const { leads } = await this.list(userId, {
283
+ status,
284
+ limit,
285
+ orderBy: 'score',
286
+ orderDir: 'desc',
287
+ })
288
+ return leads
289
+ }
290
+
291
+ /**
292
+ * Get high-score leads (score >= 80)
293
+ */
294
+ static async getHighScore(userId: string, limit = 20): Promise<Lead[]> {
295
+ const { leads } = await this.list(userId, {
296
+ minScore: 80,
297
+ limit,
298
+ orderBy: 'score',
299
+ orderDir: 'desc',
300
+ })
301
+ return leads
302
+ }
303
+
304
+ // ============================================
305
+ // WRITE METHODS
306
+ // ============================================
307
+
308
+ /**
309
+ * Create a new lead
310
+ */
311
+ static async create(userId: string, data: LeadCreateData): Promise<Lead> {
312
+ try {
313
+ if (!userId?.trim()) throw new Error('User ID is required')
314
+ if (!data.companyName?.trim()) throw new Error('Company name is required')
315
+ if (!data.contactName?.trim()) throw new Error('Contact name is required')
316
+ if (!data.email?.trim()) throw new Error('Email is required')
317
+ if (!data.teamId?.trim()) throw new Error('Team ID is required')
318
+
319
+ const id = crypto.randomUUID()
320
+ const now = new Date().toISOString()
321
+
322
+ const result = await mutateWithRLS<DbLead>(
323
+ `INSERT INTO leads (id, "userId", "teamId", "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt")
324
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
325
+ RETURNING id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"`,
326
+ [
327
+ id,
328
+ userId,
329
+ data.teamId,
330
+ data.companyName,
331
+ data.contactName,
332
+ data.email,
333
+ data.phone || null,
334
+ data.website || null,
335
+ data.source,
336
+ data.status || 'new',
337
+ data.score || null,
338
+ data.industry || null,
339
+ data.companySize || null,
340
+ data.budget || null,
341
+ data.assignedTo || null,
342
+ data.notes || null,
343
+ now,
344
+ now,
345
+ ],
346
+ userId
347
+ )
348
+
349
+ if (!result.rows[0]) throw new Error('Failed to create lead')
350
+
351
+ const lead = result.rows[0]
352
+ return {
353
+ id: lead.id,
354
+ companyName: lead.companyName,
355
+ contactName: lead.contactName,
356
+ email: lead.email,
357
+ phone: lead.phone ?? undefined,
358
+ website: lead.website ?? undefined,
359
+ source: lead.source,
360
+ status: lead.status,
361
+ score: lead.score ?? undefined,
362
+ industry: lead.industry ?? undefined,
363
+ companySize: lead.companySize ?? undefined,
364
+ budget: lead.budget ?? undefined,
365
+ assignedTo: lead.assignedTo ?? undefined,
366
+ notes: lead.notes ?? undefined,
367
+ createdAt: lead.createdAt,
368
+ updatedAt: lead.updatedAt,
369
+ }
370
+ } catch (error) {
371
+ console.error('LeadsService.create error:', error)
372
+ throw new Error(error instanceof Error ? error.message : 'Failed to create lead')
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Update an existing lead
378
+ */
379
+ static async update(userId: string, id: string, data: LeadUpdateData): Promise<Lead> {
380
+ try {
381
+ if (!userId?.trim()) throw new Error('User ID is required')
382
+ if (!id?.trim()) throw new Error('Lead ID is required')
383
+
384
+ const updates: string[] = []
385
+ const values: unknown[] = []
386
+ let paramIndex = 1
387
+
388
+ if (data.companyName !== undefined) {
389
+ updates.push(`"companyName" = $${paramIndex++}`)
390
+ values.push(data.companyName)
391
+ }
392
+ if (data.contactName !== undefined) {
393
+ updates.push(`"contactName" = $${paramIndex++}`)
394
+ values.push(data.contactName)
395
+ }
396
+ if (data.email !== undefined) {
397
+ updates.push(`email = $${paramIndex++}`)
398
+ values.push(data.email)
399
+ }
400
+ if (data.phone !== undefined) {
401
+ updates.push(`phone = $${paramIndex++}`)
402
+ values.push(data.phone || null)
403
+ }
404
+ if (data.website !== undefined) {
405
+ updates.push(`website = $${paramIndex++}`)
406
+ values.push(data.website || null)
407
+ }
408
+ if (data.source !== undefined) {
409
+ updates.push(`source = $${paramIndex++}`)
410
+ values.push(data.source)
411
+ }
412
+ if (data.status !== undefined) {
413
+ updates.push(`status = $${paramIndex++}`)
414
+ values.push(data.status)
415
+ }
416
+ if (data.score !== undefined) {
417
+ updates.push(`score = $${paramIndex++}`)
418
+ values.push(data.score)
419
+ }
420
+ if (data.industry !== undefined) {
421
+ updates.push(`industry = $${paramIndex++}`)
422
+ values.push(data.industry || null)
423
+ }
424
+ if (data.companySize !== undefined) {
425
+ updates.push(`"companySize" = $${paramIndex++}`)
426
+ values.push(data.companySize || null)
427
+ }
428
+ if (data.budget !== undefined) {
429
+ updates.push(`budget = $${paramIndex++}`)
430
+ values.push(data.budget)
431
+ }
432
+ if (data.assignedTo !== undefined) {
433
+ updates.push(`"assignedTo" = $${paramIndex++}`)
434
+ values.push(data.assignedTo || null)
435
+ }
436
+ if (data.notes !== undefined) {
437
+ updates.push(`notes = $${paramIndex++}`)
438
+ values.push(data.notes || null)
439
+ }
440
+
441
+ if (updates.length === 0) throw new Error('No fields to update')
442
+
443
+ updates.push(`"updatedAt" = $${paramIndex++}`)
444
+ values.push(new Date().toISOString())
445
+ values.push(id)
446
+
447
+ const result = await mutateWithRLS<DbLead>(
448
+ `UPDATE leads SET ${updates.join(', ')} WHERE id = $${paramIndex}
449
+ RETURNING id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"`,
450
+ values,
451
+ userId
452
+ )
453
+
454
+ if (!result.rows[0]) throw new Error('Lead not found or update failed')
455
+
456
+ const lead = result.rows[0]
457
+ return {
458
+ id: lead.id,
459
+ companyName: lead.companyName,
460
+ contactName: lead.contactName,
461
+ email: lead.email,
462
+ phone: lead.phone ?? undefined,
463
+ website: lead.website ?? undefined,
464
+ source: lead.source,
465
+ status: lead.status,
466
+ score: lead.score ?? undefined,
467
+ industry: lead.industry ?? undefined,
468
+ companySize: lead.companySize ?? undefined,
469
+ budget: lead.budget ?? undefined,
470
+ assignedTo: lead.assignedTo ?? undefined,
471
+ notes: lead.notes ?? undefined,
472
+ createdAt: lead.createdAt,
473
+ updatedAt: lead.updatedAt,
474
+ }
475
+ } catch (error) {
476
+ console.error('LeadsService.update error:', error)
477
+ throw new Error(error instanceof Error ? error.message : 'Failed to update lead')
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Delete a lead
483
+ */
484
+ static async delete(userId: string, id: string): Promise<boolean> {
485
+ try {
486
+ if (!userId?.trim()) throw new Error('User ID is required')
487
+ if (!id?.trim()) throw new Error('Lead ID is required')
488
+
489
+ const result = await mutateWithRLS(`DELETE FROM leads WHERE id = $1`, [id], userId)
490
+ return result.rowCount > 0
491
+ } catch (error) {
492
+ console.error('LeadsService.delete error:', error)
493
+ throw new Error(error instanceof Error ? error.message : 'Failed to delete lead')
494
+ }
495
+ }
496
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Lead Service Types
3
+ *
4
+ * Type definitions for the LeadService.
5
+ * Defines types for lead management including source tracking,
6
+ * status progression, and qualification scoring.
7
+ *
8
+ * @module LeadTypes
9
+ */
10
+
11
+ // Type literals for select fields
12
+ export type LeadSource =
13
+ | 'web'
14
+ | 'referral'
15
+ | 'cold_call'
16
+ | 'trade_show'
17
+ | 'social_media'
18
+ | 'email'
19
+ | 'advertising'
20
+ | 'partner'
21
+ | 'other'
22
+
23
+ export type LeadStatus =
24
+ | 'new'
25
+ | 'contacted'
26
+ | 'qualified'
27
+ | 'proposal'
28
+ | 'negotiation'
29
+ | 'converted'
30
+ | 'lost'
31
+
32
+ export type CompanySize = '1-10' | '11-50' | '51-200' | '201-500' | '500+'
33
+
34
+ // Main entity interface
35
+ export interface Lead {
36
+ id: string
37
+ teamId: string
38
+ companyName: string
39
+ contactName: string
40
+ email: string
41
+ phone?: string | null
42
+ website?: string | null
43
+ source?: LeadSource | null
44
+ status?: LeadStatus | null
45
+ score?: number | null
46
+ industry?: string | null
47
+ companySize?: CompanySize | null
48
+ budget?: number | null
49
+ assignedTo?: string | null
50
+ notes?: string | null
51
+ createdAt: string
52
+ updatedAt: string
53
+ }
54
+
55
+ // List options
56
+ export interface LeadListOptions {
57
+ limit?: number
58
+ offset?: number
59
+ teamId?: string
60
+ source?: LeadSource
61
+ status?: LeadStatus
62
+ companySize?: CompanySize
63
+ assignedTo?: string
64
+ orderBy?:
65
+ | 'companyName'
66
+ | 'contactName'
67
+ | 'email'
68
+ | 'score'
69
+ | 'industry'
70
+ | 'createdAt'
71
+ | 'updatedAt'
72
+ orderDir?: 'asc' | 'desc'
73
+ }
74
+
75
+ // List result
76
+ export interface LeadListResult {
77
+ leads: Lead[]
78
+ total: number
79
+ }
80
+
81
+ // Create data (required fields + teamId + optional fields)
82
+ export interface LeadCreateData {
83
+ companyName: string
84
+ contactName: string
85
+ email: string
86
+ teamId: string
87
+ phone?: string
88
+ website?: string
89
+ source?: LeadSource
90
+ status?: LeadStatus
91
+ score?: number
92
+ industry?: string
93
+ companySize?: CompanySize
94
+ budget?: number
95
+ assignedTo?: string
96
+ notes?: string
97
+ }
98
+
99
+ // Update data (all fields optional)
100
+ export interface LeadUpdateData {
101
+ companyName?: string
102
+ contactName?: string
103
+ email?: string
104
+ phone?: string | null
105
+ website?: string | null
106
+ source?: LeadSource | null
107
+ status?: LeadStatus | null
108
+ score?: number | null
109
+ industry?: string | null
110
+ companySize?: CompanySize | null
111
+ budget?: number | null
112
+ assignedTo?: string | null
113
+ notes?: string | null
114
+ }