@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,525 @@
1
+ /**
2
+ * Opportunities Service
3
+ *
4
+ * Provides data access methods for opportunities.
5
+ * Opportunities is a private entity - users only see opportunities in their team.
6
+ *
7
+ * All methods require authentication (use RLS with userId filter).
8
+ *
9
+ * @module OpportunitiesService
10
+ */
11
+
12
+ import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
13
+
14
+ // Opportunity status type
15
+ export type OpportunityStatus = 'open' | 'won' | 'lost'
16
+
17
+ // Opportunity interface
18
+ export interface Opportunity {
19
+ id: string
20
+ name: string
21
+ contactId?: string
22
+ companyId?: string
23
+ pipelineId?: string
24
+ stageId?: string
25
+ amount?: number
26
+ probability?: number
27
+ expectedCloseDate?: string
28
+ status: OpportunityStatus
29
+ assignedTo?: string
30
+ description?: string
31
+ createdAt: string
32
+ updatedAt: string
33
+ }
34
+
35
+ // List options
36
+ export interface OpportunityListOptions {
37
+ limit?: number
38
+ offset?: number
39
+ contactId?: string
40
+ companyId?: string
41
+ pipelineId?: string
42
+ stageId?: string
43
+ status?: OpportunityStatus
44
+ minAmount?: number
45
+ maxAmount?: number
46
+ assignedTo?: string
47
+ orderBy?: 'name' | 'amount' | 'expectedCloseDate' | 'createdAt' | 'updatedAt'
48
+ orderDir?: 'asc' | 'desc'
49
+ teamId?: string
50
+ }
51
+
52
+ // List result
53
+ export interface OpportunityListResult {
54
+ opportunities: Opportunity[]
55
+ total: number
56
+ }
57
+
58
+ // Create data
59
+ export interface OpportunityCreateData {
60
+ name: string
61
+ contactId?: string
62
+ companyId?: string
63
+ pipelineId?: string
64
+ stageId?: string
65
+ amount?: number
66
+ probability?: number
67
+ expectedCloseDate?: string
68
+ status?: OpportunityStatus
69
+ assignedTo?: string
70
+ description?: string
71
+ teamId: string
72
+ }
73
+
74
+ // Update data
75
+ export interface OpportunityUpdateData {
76
+ name?: string
77
+ contactId?: string
78
+ companyId?: string
79
+ pipelineId?: string
80
+ stageId?: string
81
+ amount?: number
82
+ probability?: number
83
+ expectedCloseDate?: string
84
+ status?: OpportunityStatus
85
+ assignedTo?: string
86
+ description?: string
87
+ }
88
+
89
+ // Database row type
90
+ interface DbOpportunity {
91
+ id: string
92
+ name: string
93
+ contactId: string | null
94
+ companyId: string | null
95
+ pipelineId: string | null
96
+ stageId: string | null
97
+ amount: number | null
98
+ probability: number | null
99
+ expectedCloseDate: string | null
100
+ status: OpportunityStatus
101
+ assignedTo: string | null
102
+ description: string | null
103
+ createdAt: string
104
+ updatedAt: string
105
+ }
106
+
107
+ export class OpportunitiesService {
108
+ // ============================================
109
+ // READ METHODS
110
+ // ============================================
111
+
112
+ /**
113
+ * Get an opportunity by ID
114
+ */
115
+ static async getById(id: string, userId: string): Promise<Opportunity | null> {
116
+ try {
117
+ if (!id?.trim()) throw new Error('Opportunity ID is required')
118
+ if (!userId?.trim()) throw new Error('User ID is required')
119
+
120
+ const opportunity = await queryOneWithRLS<DbOpportunity>(
121
+ `SELECT id, name, "contactId", "companyId", "pipelineId", "stageId", amount, probability, "expectedCloseDate", status, "assignedTo", description, "createdAt", "updatedAt"
122
+ FROM opportunities WHERE id = $1`,
123
+ [id],
124
+ userId
125
+ )
126
+
127
+ if (!opportunity) return null
128
+
129
+ return {
130
+ id: opportunity.id,
131
+ name: opportunity.name,
132
+ contactId: opportunity.contactId ?? undefined,
133
+ companyId: opportunity.companyId ?? undefined,
134
+ pipelineId: opportunity.pipelineId ?? undefined,
135
+ stageId: opportunity.stageId ?? undefined,
136
+ amount: opportunity.amount ?? undefined,
137
+ probability: opportunity.probability ?? undefined,
138
+ expectedCloseDate: opportunity.expectedCloseDate ?? undefined,
139
+ status: opportunity.status,
140
+ assignedTo: opportunity.assignedTo ?? undefined,
141
+ description: opportunity.description ?? undefined,
142
+ createdAt: opportunity.createdAt,
143
+ updatedAt: opportunity.updatedAt,
144
+ }
145
+ } catch (error) {
146
+ console.error('OpportunitiesService.getById error:', error)
147
+ throw new Error(error instanceof Error ? error.message : 'Failed to fetch opportunity')
148
+ }
149
+ }
150
+
151
+ /**
152
+ * List opportunities with pagination and filtering
153
+ */
154
+ static async list(userId: string, options: OpportunityListOptions = {}): Promise<OpportunityListResult> {
155
+ try {
156
+ if (!userId?.trim()) throw new Error('User ID is required')
157
+
158
+ const {
159
+ limit = 10,
160
+ offset = 0,
161
+ contactId,
162
+ companyId,
163
+ pipelineId,
164
+ stageId,
165
+ status,
166
+ minAmount,
167
+ maxAmount,
168
+ assignedTo,
169
+ orderBy = 'createdAt',
170
+ orderDir = 'desc',
171
+ teamId,
172
+ } = options
173
+
174
+ // Build WHERE clause
175
+ const conditions: string[] = []
176
+ const params: unknown[] = []
177
+ let paramIndex = 1
178
+
179
+ if (contactId) {
180
+ conditions.push(`"contactId" = $${paramIndex++}`)
181
+ params.push(contactId)
182
+ }
183
+
184
+ if (companyId) {
185
+ conditions.push(`"companyId" = $${paramIndex++}`)
186
+ params.push(companyId)
187
+ }
188
+
189
+ if (pipelineId) {
190
+ conditions.push(`"pipelineId" = $${paramIndex++}`)
191
+ params.push(pipelineId)
192
+ }
193
+
194
+ if (stageId) {
195
+ conditions.push(`"stageId" = $${paramIndex++}`)
196
+ params.push(stageId)
197
+ }
198
+
199
+ if (status) {
200
+ conditions.push(`status = $${paramIndex++}`)
201
+ params.push(status)
202
+ }
203
+
204
+ if (minAmount !== undefined) {
205
+ conditions.push(`amount >= $${paramIndex++}`)
206
+ params.push(minAmount)
207
+ }
208
+
209
+ if (maxAmount !== undefined) {
210
+ conditions.push(`amount <= $${paramIndex++}`)
211
+ params.push(maxAmount)
212
+ }
213
+
214
+ if (assignedTo) {
215
+ conditions.push(`"assignedTo" = $${paramIndex++}`)
216
+ params.push(assignedTo)
217
+ }
218
+
219
+ if (teamId) {
220
+ conditions.push(`"teamId" = $${paramIndex++}`)
221
+ params.push(teamId)
222
+ }
223
+
224
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
225
+
226
+ // Validate orderBy
227
+ const validOrderBy = ['name', 'amount', 'expectedCloseDate', 'createdAt', 'updatedAt'].includes(orderBy) ? orderBy : 'createdAt'
228
+ const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
229
+ const orderColumnMap: Record<string, string> = {
230
+ name: 'name',
231
+ amount: 'amount',
232
+ expectedCloseDate: '"expectedCloseDate"',
233
+ createdAt: '"createdAt"',
234
+ updatedAt: '"updatedAt"',
235
+ }
236
+ const orderColumn = orderColumnMap[validOrderBy] || '"createdAt"'
237
+
238
+ // Get total count
239
+ const countResult = await queryWithRLS<{ count: string }>(
240
+ `SELECT COUNT(*)::text as count FROM opportunities ${whereClause}`,
241
+ params,
242
+ userId
243
+ )
244
+ const total = parseInt(countResult[0]?.count || '0', 10)
245
+
246
+ // Get opportunities
247
+ params.push(limit, offset)
248
+ const opportunities = await queryWithRLS<DbOpportunity>(
249
+ `SELECT id, name, "contactId", "companyId", "pipelineId", "stageId", amount, probability, "expectedCloseDate", status, "assignedTo", description, "createdAt", "updatedAt"
250
+ FROM opportunities ${whereClause}
251
+ ORDER BY ${orderColumn} ${validOrderDir}
252
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
253
+ params,
254
+ userId
255
+ )
256
+
257
+ return {
258
+ opportunities: opportunities.map((opportunity) => ({
259
+ id: opportunity.id,
260
+ name: opportunity.name,
261
+ contactId: opportunity.contactId ?? undefined,
262
+ companyId: opportunity.companyId ?? undefined,
263
+ pipelineId: opportunity.pipelineId ?? undefined,
264
+ stageId: opportunity.stageId ?? undefined,
265
+ amount: opportunity.amount ?? undefined,
266
+ probability: opportunity.probability ?? undefined,
267
+ expectedCloseDate: opportunity.expectedCloseDate ?? undefined,
268
+ status: opportunity.status,
269
+ assignedTo: opportunity.assignedTo ?? undefined,
270
+ description: opportunity.description ?? undefined,
271
+ createdAt: opportunity.createdAt,
272
+ updatedAt: opportunity.updatedAt,
273
+ })),
274
+ total,
275
+ }
276
+ } catch (error) {
277
+ console.error('OpportunitiesService.list error:', error)
278
+ throw new Error(error instanceof Error ? error.message : 'Failed to list opportunities')
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Get opportunities by status
284
+ */
285
+ static async getByStatus(userId: string, status: OpportunityStatus, limit = 50): Promise<Opportunity[]> {
286
+ const { opportunities } = await this.list(userId, {
287
+ status,
288
+ limit,
289
+ orderBy: 'amount',
290
+ orderDir: 'desc',
291
+ })
292
+ return opportunities
293
+ }
294
+
295
+ /**
296
+ * Get opportunities by company
297
+ */
298
+ static async getByCompany(userId: string, companyId: string, limit = 50): Promise<Opportunity[]> {
299
+ const { opportunities } = await this.list(userId, {
300
+ companyId,
301
+ limit,
302
+ orderBy: 'expectedCloseDate',
303
+ orderDir: 'asc',
304
+ })
305
+ return opportunities
306
+ }
307
+
308
+ /**
309
+ * Get opportunities closing soon (within 30 days)
310
+ */
311
+ static async getClosingSoon(userId: string, limit = 20): Promise<Opportunity[]> {
312
+ try {
313
+ if (!userId?.trim()) throw new Error('User ID is required')
314
+
315
+ const opportunities = await queryWithRLS<DbOpportunity>(
316
+ `SELECT id, name, "contactId", "companyId", "pipelineId", "stageId", amount, probability, "expectedCloseDate", status, "assignedTo", description, "createdAt", "updatedAt"
317
+ FROM opportunities
318
+ WHERE status = 'open'
319
+ AND "expectedCloseDate" IS NOT NULL
320
+ AND "expectedCloseDate" <= CURRENT_DATE + INTERVAL '30 days'
321
+ ORDER BY "expectedCloseDate" ASC
322
+ LIMIT $1`,
323
+ [limit],
324
+ userId
325
+ )
326
+
327
+ return opportunities.map((opportunity) => ({
328
+ id: opportunity.id,
329
+ name: opportunity.name,
330
+ contactId: opportunity.contactId ?? undefined,
331
+ companyId: opportunity.companyId ?? undefined,
332
+ pipelineId: opportunity.pipelineId ?? undefined,
333
+ stageId: opportunity.stageId ?? undefined,
334
+ amount: opportunity.amount ?? undefined,
335
+ probability: opportunity.probability ?? undefined,
336
+ expectedCloseDate: opportunity.expectedCloseDate ?? undefined,
337
+ status: opportunity.status,
338
+ assignedTo: opportunity.assignedTo ?? undefined,
339
+ description: opportunity.description ?? undefined,
340
+ createdAt: opportunity.createdAt,
341
+ updatedAt: opportunity.updatedAt,
342
+ }))
343
+ } catch (error) {
344
+ console.error('OpportunitiesService.getClosingSoon error:', error)
345
+ throw new Error(error instanceof Error ? error.message : 'Failed to fetch closing soon opportunities')
346
+ }
347
+ }
348
+
349
+ // ============================================
350
+ // WRITE METHODS
351
+ // ============================================
352
+
353
+ /**
354
+ * Create a new opportunity
355
+ */
356
+ static async create(userId: string, data: OpportunityCreateData): Promise<Opportunity> {
357
+ try {
358
+ if (!userId?.trim()) throw new Error('User ID is required')
359
+ if (!data.name?.trim()) throw new Error('Opportunity name is required')
360
+ if (!data.teamId?.trim()) throw new Error('Team ID is required')
361
+
362
+ const id = crypto.randomUUID()
363
+ const now = new Date().toISOString()
364
+
365
+ const result = await mutateWithRLS<DbOpportunity>(
366
+ `INSERT INTO opportunities (id, "userId", "teamId", name, "contactId", "companyId", "pipelineId", "stageId", amount, probability, "expectedCloseDate", status, "assignedTo", description, "createdAt", "updatedAt")
367
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
368
+ RETURNING id, name, "contactId", "companyId", "pipelineId", "stageId", amount, probability, "expectedCloseDate", status, "assignedTo", description, "createdAt", "updatedAt"`,
369
+ [
370
+ id,
371
+ userId,
372
+ data.teamId,
373
+ data.name,
374
+ data.contactId || null,
375
+ data.companyId || null,
376
+ data.pipelineId || null,
377
+ data.stageId || null,
378
+ data.amount || null,
379
+ data.probability || null,
380
+ data.expectedCloseDate || null,
381
+ data.status || 'open',
382
+ data.assignedTo || null,
383
+ data.description || null,
384
+ now,
385
+ now,
386
+ ],
387
+ userId
388
+ )
389
+
390
+ if (!result.rows[0]) throw new Error('Failed to create opportunity')
391
+
392
+ const opportunity = result.rows[0]
393
+ return {
394
+ id: opportunity.id,
395
+ name: opportunity.name,
396
+ contactId: opportunity.contactId ?? undefined,
397
+ companyId: opportunity.companyId ?? undefined,
398
+ pipelineId: opportunity.pipelineId ?? undefined,
399
+ stageId: opportunity.stageId ?? undefined,
400
+ amount: opportunity.amount ?? undefined,
401
+ probability: opportunity.probability ?? undefined,
402
+ expectedCloseDate: opportunity.expectedCloseDate ?? undefined,
403
+ status: opportunity.status,
404
+ assignedTo: opportunity.assignedTo ?? undefined,
405
+ description: opportunity.description ?? undefined,
406
+ createdAt: opportunity.createdAt,
407
+ updatedAt: opportunity.updatedAt,
408
+ }
409
+ } catch (error) {
410
+ console.error('OpportunitiesService.create error:', error)
411
+ throw new Error(error instanceof Error ? error.message : 'Failed to create opportunity')
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Update an existing opportunity
417
+ */
418
+ static async update(userId: string, id: string, data: OpportunityUpdateData): Promise<Opportunity> {
419
+ try {
420
+ if (!userId?.trim()) throw new Error('User ID is required')
421
+ if (!id?.trim()) throw new Error('Opportunity ID is required')
422
+
423
+ const updates: string[] = []
424
+ const values: unknown[] = []
425
+ let paramIndex = 1
426
+
427
+ if (data.name !== undefined) {
428
+ updates.push(`name = $${paramIndex++}`)
429
+ values.push(data.name)
430
+ }
431
+ if (data.contactId !== undefined) {
432
+ updates.push(`"contactId" = $${paramIndex++}`)
433
+ values.push(data.contactId || null)
434
+ }
435
+ if (data.companyId !== undefined) {
436
+ updates.push(`"companyId" = $${paramIndex++}`)
437
+ values.push(data.companyId || null)
438
+ }
439
+ if (data.pipelineId !== undefined) {
440
+ updates.push(`"pipelineId" = $${paramIndex++}`)
441
+ values.push(data.pipelineId || null)
442
+ }
443
+ if (data.stageId !== undefined) {
444
+ updates.push(`"stageId" = $${paramIndex++}`)
445
+ values.push(data.stageId || null)
446
+ }
447
+ if (data.amount !== undefined) {
448
+ updates.push(`amount = $${paramIndex++}`)
449
+ values.push(data.amount)
450
+ }
451
+ if (data.probability !== undefined) {
452
+ updates.push(`probability = $${paramIndex++}`)
453
+ values.push(data.probability)
454
+ }
455
+ if (data.expectedCloseDate !== undefined) {
456
+ updates.push(`"expectedCloseDate" = $${paramIndex++}`)
457
+ values.push(data.expectedCloseDate || null)
458
+ }
459
+ if (data.status !== undefined) {
460
+ updates.push(`status = $${paramIndex++}`)
461
+ values.push(data.status)
462
+ }
463
+ if (data.assignedTo !== undefined) {
464
+ updates.push(`"assignedTo" = $${paramIndex++}`)
465
+ values.push(data.assignedTo || null)
466
+ }
467
+ if (data.description !== undefined) {
468
+ updates.push(`description = $${paramIndex++}`)
469
+ values.push(data.description || null)
470
+ }
471
+
472
+ if (updates.length === 0) throw new Error('No fields to update')
473
+
474
+ updates.push(`"updatedAt" = $${paramIndex++}`)
475
+ values.push(new Date().toISOString())
476
+ values.push(id)
477
+
478
+ const result = await mutateWithRLS<DbOpportunity>(
479
+ `UPDATE opportunities SET ${updates.join(', ')} WHERE id = $${paramIndex}
480
+ RETURNING id, name, "contactId", "companyId", "pipelineId", "stageId", amount, probability, "expectedCloseDate", status, "assignedTo", description, "createdAt", "updatedAt"`,
481
+ values,
482
+ userId
483
+ )
484
+
485
+ if (!result.rows[0]) throw new Error('Opportunity not found or update failed')
486
+
487
+ const opportunity = result.rows[0]
488
+ return {
489
+ id: opportunity.id,
490
+ name: opportunity.name,
491
+ contactId: opportunity.contactId ?? undefined,
492
+ companyId: opportunity.companyId ?? undefined,
493
+ pipelineId: opportunity.pipelineId ?? undefined,
494
+ stageId: opportunity.stageId ?? undefined,
495
+ amount: opportunity.amount ?? undefined,
496
+ probability: opportunity.probability ?? undefined,
497
+ expectedCloseDate: opportunity.expectedCloseDate ?? undefined,
498
+ status: opportunity.status,
499
+ assignedTo: opportunity.assignedTo ?? undefined,
500
+ description: opportunity.description ?? undefined,
501
+ createdAt: opportunity.createdAt,
502
+ updatedAt: opportunity.updatedAt,
503
+ }
504
+ } catch (error) {
505
+ console.error('OpportunitiesService.update error:', error)
506
+ throw new Error(error instanceof Error ? error.message : 'Failed to update opportunity')
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Delete an opportunity
512
+ */
513
+ static async delete(userId: string, id: string): Promise<boolean> {
514
+ try {
515
+ if (!userId?.trim()) throw new Error('User ID is required')
516
+ if (!id?.trim()) throw new Error('Opportunity ID is required')
517
+
518
+ const result = await mutateWithRLS(`DELETE FROM opportunities WHERE id = $1`, [id], userId)
519
+ return result.rowCount > 0
520
+ } catch (error) {
521
+ console.error('OpportunitiesService.delete error:', error)
522
+ throw new Error(error instanceof Error ? error.message : 'Failed to delete opportunity')
523
+ }
524
+ }
525
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Opportunity Service Types
3
+ *
4
+ * Type definitions for the OpportunityService.
5
+ * Defines types for sales opportunity management including pipeline tracking,
6
+ * deal amounts, and win probability calculations.
7
+ *
8
+ * @module OpportunityTypes
9
+ */
10
+
11
+ // Type literals for select fields
12
+ export type OpportunityStatus = 'open' | 'won' | 'lost' | 'abandoned'
13
+
14
+ export type OpportunityType =
15
+ | 'new_business'
16
+ | 'existing_business'
17
+ | 'renewal'
18
+ | 'upgrade'
19
+ | 'downgrade'
20
+
21
+ export type OpportunitySource =
22
+ | 'web'
23
+ | 'referral'
24
+ | 'cold_call'
25
+ | 'trade_show'
26
+ | 'social_media'
27
+ | 'email'
28
+ | 'advertising'
29
+ | 'partner'
30
+ | 'other'
31
+
32
+ export type Currency =
33
+ | 'USD'
34
+ | 'EUR'
35
+ | 'GBP'
36
+ | 'MXN'
37
+ | 'CAD'
38
+ | 'AUD'
39
+ | 'JPY'
40
+ | 'CNY'
41
+ | 'INR'
42
+ | 'BRL'
43
+
44
+ // Main entity interface
45
+ export interface Opportunity {
46
+ id: string
47
+ teamId: string
48
+ name: string
49
+ companyId: string
50
+ contactId?: string | null
51
+ pipelineId: string
52
+ stageId: string
53
+ amount: number
54
+ currency?: Currency | null
55
+ probability?: number | null
56
+ expectedRevenue?: number | null
57
+ closeDate: string
58
+ type?: OpportunityType | null
59
+ source?: OpportunitySource | null
60
+ competitor?: string | null
61
+ status?: OpportunityStatus | null
62
+ lostReason?: string | null
63
+ assignedTo?: string | null
64
+ createdAt: string
65
+ updatedAt: string
66
+ }
67
+
68
+ // List options
69
+ export interface OpportunityListOptions {
70
+ limit?: number
71
+ offset?: number
72
+ teamId?: string
73
+ companyId?: string
74
+ contactId?: string
75
+ pipelineId?: string
76
+ stageId?: string
77
+ status?: OpportunityStatus
78
+ type?: OpportunityType
79
+ source?: OpportunitySource
80
+ currency?: Currency
81
+ assignedTo?: string
82
+ orderBy?:
83
+ | 'name'
84
+ | 'amount'
85
+ | 'probability'
86
+ | 'expectedRevenue'
87
+ | 'closeDate'
88
+ | 'createdAt'
89
+ | 'updatedAt'
90
+ orderDir?: 'asc' | 'desc'
91
+ }
92
+
93
+ // List result
94
+ export interface OpportunityListResult {
95
+ opportunities: Opportunity[]
96
+ total: number
97
+ }
98
+
99
+ // Create data (required fields + teamId + optional fields)
100
+ export interface OpportunityCreateData {
101
+ name: string
102
+ companyId: string
103
+ pipelineId: string
104
+ stageId: string
105
+ amount: number
106
+ closeDate: string
107
+ teamId: string
108
+ contactId?: string
109
+ currency?: Currency
110
+ probability?: number
111
+ type?: OpportunityType
112
+ source?: OpportunitySource
113
+ competitor?: string
114
+ status?: OpportunityStatus
115
+ assignedTo?: string
116
+ }
117
+
118
+ // Update data (all fields optional)
119
+ export interface OpportunityUpdateData {
120
+ name?: string
121
+ companyId?: string
122
+ contactId?: string | null
123
+ pipelineId?: string
124
+ stageId?: string
125
+ amount?: number
126
+ currency?: Currency | null
127
+ probability?: number | null
128
+ closeDate?: string
129
+ type?: OpportunityType | null
130
+ source?: OpportunitySource | null
131
+ competitor?: string | null
132
+ status?: OpportunityStatus | null
133
+ lostReason?: string | null
134
+ assignedTo?: string | null
135
+ }