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