@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.
- package/CRM_PLAN.md +343 -0
- package/about.md +122 -0
- package/config/app.config.ts +185 -0
- package/config/billing.config.ts +187 -0
- package/config/dashboard.config.ts +372 -0
- package/config/dev.config.ts +55 -0
- package/config/features.config.ts +336 -0
- package/config/flows.config.ts +511 -0
- package/config/permissions.config.ts +297 -0
- package/config/theme.config.ts +111 -0
- package/entities/activities/activities.config.ts +61 -0
- package/entities/activities/activities.fields.ts +362 -0
- package/entities/activities/activities.service.ts +503 -0
- package/entities/activities/activities.types.ts +117 -0
- package/entities/activities/messages/en.json +123 -0
- package/entities/activities/messages/es.json +123 -0
- package/entities/activities/migrations/020_activities_table.sql +123 -0
- package/entities/activities/migrations/021_activities_metas.sql +114 -0
- package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
- package/entities/campaigns/campaigns.config.ts +61 -0
- package/entities/campaigns/campaigns.fields.ts +413 -0
- package/entities/campaigns/campaigns.service.ts +426 -0
- package/entities/campaigns/campaigns.types.ts +124 -0
- package/entities/campaigns/messages/en.json +145 -0
- package/entities/campaigns/messages/es.json +145 -0
- package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
- package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
- package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
- package/entities/companies/companies.config.ts +61 -0
- package/entities/companies/companies.fields.ts +429 -0
- package/entities/companies/companies.service.ts +566 -0
- package/entities/companies/companies.types.ts +125 -0
- package/entities/companies/messages/en.json +146 -0
- package/entities/companies/messages/es.json +146 -0
- package/entities/companies/migrations/001_companies_table.sql +150 -0
- package/entities/companies/migrations/002_companies_metas.sql +114 -0
- package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
- package/entities/contacts/contacts.config.ts +61 -0
- package/entities/contacts/contacts.fields.ts +359 -0
- package/entities/contacts/contacts.service.ts +509 -0
- package/entities/contacts/contacts.types.ts +108 -0
- package/entities/contacts/messages/en.json +117 -0
- package/entities/contacts/messages/es.json +117 -0
- package/entities/contacts/migrations/001_contacts_table.sql +134 -0
- package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
- package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
- package/entities/leads/leads.config.ts +61 -0
- package/entities/leads/leads.fields.ts +336 -0
- package/entities/leads/leads.service.ts +496 -0
- package/entities/leads/leads.types.ts +114 -0
- package/entities/leads/messages/en.json +132 -0
- package/entities/leads/messages/es.json +132 -0
- package/entities/leads/migrations/001_leads_table.sql +150 -0
- package/entities/leads/migrations/002_leads_metas.sql +120 -0
- package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
- package/entities/notes/messages/en.json +114 -0
- package/entities/notes/messages/es.json +114 -0
- package/entities/notes/migrations/020_notes_table.sql +118 -0
- package/entities/notes/migrations/021_notes_metas.sql +114 -0
- package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
- package/entities/notes/notes.config.ts +61 -0
- package/entities/notes/notes.fields.ts +283 -0
- package/entities/notes/notes.service.ts +320 -0
- package/entities/notes/notes.types.ts +102 -0
- package/entities/opportunities/messages/en.json +107 -0
- package/entities/opportunities/messages/es.json +107 -0
- package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
- package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
- package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
- package/entities/opportunities/opportunities.config.ts +61 -0
- package/entities/opportunities/opportunities.fields.ts +416 -0
- package/entities/opportunities/opportunities.service.ts +525 -0
- package/entities/opportunities/opportunities.types.ts +135 -0
- package/entities/pipelines/messages/en.json +115 -0
- package/entities/pipelines/messages/es.json +115 -0
- package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
- package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
- package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
- package/entities/pipelines/pipelines.config.ts +62 -0
- package/entities/pipelines/pipelines.fields.ts +193 -0
- package/entities/pipelines/pipelines.service.ts +383 -0
- package/entities/pipelines/pipelines.types.ts +78 -0
- package/entities/products/messages/en.json +135 -0
- package/entities/products/messages/es.json +135 -0
- package/entities/products/migrations/001_products_table.sql +117 -0
- package/entities/products/migrations/002_products_metas.sql +114 -0
- package/entities/products/migrations/003_products_sample_data.sql +247 -0
- package/entities/products/products.config.ts +62 -0
- package/entities/products/products.fields.ts +361 -0
- package/entities/products/products.service.ts +437 -0
- package/entities/products/products.types.ts +125 -0
- package/lib/crm-constants.ts +77 -0
- package/lib/crm-utils.ts +185 -0
- package/lib/selectors.ts +333 -0
- package/messages/en.json +131 -0
- package/messages/es.json +131 -0
- package/migrations/999_theme_sample_data.sql +473 -0
- package/package.json +18 -0
- package/pendings.md +205 -0
- package/permissions-matrix.md +216 -0
- package/styles/components.css +414 -0
- package/styles/crm-theme.css +358 -0
- package/styles/globals.css +576 -0
- package/styles/variables.css +111 -0
- package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
- package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
- package/templates/dashboard/(main)/activities/page.tsx +297 -0
- package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
- package/templates/dashboard/(main)/companies/page.tsx +296 -0
- package/templates/dashboard/(main)/contacts/page.tsx +347 -0
- package/templates/dashboard/(main)/layout.tsx +98 -0
- package/templates/dashboard/(main)/leads/page.tsx +335 -0
- package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
- package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
- package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
- package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
- package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
- package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
- package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
- package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
- package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
- package/templates/dashboard/(main)/products/create/page.tsx +96 -0
- package/templates/dashboard/(main)/products/page.tsx +308 -0
- package/templates/shared/ActionButtons.tsx +41 -0
- package/templates/shared/CRMDashboard.tsx +519 -0
- package/templates/shared/CRMDataTable.tsx +441 -0
- package/templates/shared/CRMMetricCard.tsx +76 -0
- package/templates/shared/CRMMobileNav.tsx +172 -0
- package/templates/shared/CRMSidebar.tsx +346 -0
- package/templates/shared/CRMTopBar.tsx +265 -0
- package/templates/shared/DealCard.tsx +123 -0
- package/templates/shared/EntityCard.tsx +58 -0
- package/templates/shared/OpportunityForm.tsx +649 -0
- package/templates/shared/PipelineForm.tsx +367 -0
- package/templates/shared/PipelineKanban.tsx +194 -0
- package/templates/shared/QuickFilters.tsx +47 -0
- package/templates/shared/StageColumn.tsx +175 -0
- package/templates/shared/StageSelect.tsx +177 -0
- package/templates/shared/StagesRepeater.tsx +317 -0
- 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
|
+
}
|