@nextsparkjs/core 0.1.0-beta.65 → 0.1.0-beta.67

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.
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Patterns Service
3
+ *
4
+ * Provides data access methods for patterns.
5
+ * Patterns is a team-scoped entity (shared: true) - all team members see the same patterns.
6
+ *
7
+ * All methods require authentication and use RLS with team isolation.
8
+ *
9
+ * @module PatternsService
10
+ */
11
+
12
+ import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '../../lib/db'
13
+ import type {
14
+ Pattern,
15
+ CreatePatternInput,
16
+ UpdatePatternInput,
17
+ PatternListOptions,
18
+ PatternListResult,
19
+ PatternStatus
20
+ } from './patterns.types'
21
+ import { isPatternReference } from './patterns.types'
22
+
23
+ // Database row type for pattern
24
+ interface DbPattern {
25
+ id: string
26
+ userId: string
27
+ teamId: string
28
+ title: string
29
+ slug: string
30
+ blocks: unknown // JSONB
31
+ status: PatternStatus
32
+ description: string | null
33
+ createdAt: string
34
+ updatedAt: string
35
+ }
36
+
37
+ /**
38
+ * Helper function to check if blocks contain pattern references
39
+ * Used to prevent nesting patterns inside patterns
40
+ */
41
+ function containsPatternReference(blocks: unknown[]): boolean {
42
+ if (!Array.isArray(blocks)) return false
43
+ return blocks.some(block => isPatternReference(block))
44
+ }
45
+
46
+ export class PatternsService {
47
+ // ============================================
48
+ // READ OPERATIONS
49
+ // ============================================
50
+
51
+ /**
52
+ * Get a pattern by ID
53
+ *
54
+ * Respects RLS policies. Since patterns has shared: true,
55
+ * all team members can access patterns from their team.
56
+ *
57
+ * @param id - Pattern ID
58
+ * @param userId - Current user ID for RLS
59
+ * @returns Pattern data or null if not found/not authorized
60
+ *
61
+ * @example
62
+ * const pattern = await PatternsService.getById('pattern-uuid', currentUserId)
63
+ */
64
+ static async getById(
65
+ id: string,
66
+ userId: string
67
+ ): Promise<Pattern | null> {
68
+ try {
69
+ if (!id?.trim()) {
70
+ throw new Error('Pattern ID is required')
71
+ }
72
+
73
+ if (!userId?.trim()) {
74
+ throw new Error('User ID is required for authentication')
75
+ }
76
+
77
+ const pattern = await queryOneWithRLS<DbPattern>(
78
+ `
79
+ SELECT
80
+ id,
81
+ "userId",
82
+ "teamId",
83
+ title,
84
+ slug,
85
+ blocks,
86
+ status,
87
+ description,
88
+ "createdAt",
89
+ "updatedAt"
90
+ FROM "patterns"
91
+ WHERE id = $1
92
+ `,
93
+ [id],
94
+ userId
95
+ )
96
+
97
+ if (!pattern) {
98
+ return null
99
+ }
100
+
101
+ return {
102
+ id: pattern.id,
103
+ userId: pattern.userId,
104
+ teamId: pattern.teamId,
105
+ title: pattern.title,
106
+ slug: pattern.slug,
107
+ blocks: Array.isArray(pattern.blocks) ? pattern.blocks : [],
108
+ status: pattern.status,
109
+ description: pattern.description ?? undefined,
110
+ createdAt: pattern.createdAt,
111
+ updatedAt: pattern.updatedAt
112
+ }
113
+ } catch (error) {
114
+ console.error('[PatternsService.getById] Error:', error)
115
+ throw new Error(
116
+ error instanceof Error ? error.message : 'Failed to fetch pattern'
117
+ )
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get a pattern by slug and team ID
123
+ *
124
+ * @param slug - Pattern slug
125
+ * @param teamId - Team ID
126
+ * @param userId - Current user ID for RLS
127
+ * @returns Pattern data or null if not found
128
+ *
129
+ * @example
130
+ * const pattern = await PatternsService.getBySlug('newsletter-cta', teamId, userId)
131
+ */
132
+ static async getBySlug(
133
+ slug: string,
134
+ teamId: string,
135
+ userId: string
136
+ ): Promise<Pattern | null> {
137
+ try {
138
+ if (!slug?.trim()) {
139
+ throw new Error('Slug is required')
140
+ }
141
+
142
+ if (!teamId?.trim()) {
143
+ throw new Error('Team ID is required')
144
+ }
145
+
146
+ if (!userId?.trim()) {
147
+ throw new Error('User ID is required for authentication')
148
+ }
149
+
150
+ const pattern = await queryOneWithRLS<DbPattern>(
151
+ `
152
+ SELECT
153
+ id,
154
+ "userId",
155
+ "teamId",
156
+ title,
157
+ slug,
158
+ blocks,
159
+ status,
160
+ description,
161
+ "createdAt",
162
+ "updatedAt"
163
+ FROM "patterns"
164
+ WHERE slug = $1 AND "teamId" = $2
165
+ `,
166
+ [slug, teamId],
167
+ userId
168
+ )
169
+
170
+ if (!pattern) {
171
+ return null
172
+ }
173
+
174
+ return {
175
+ id: pattern.id,
176
+ userId: pattern.userId,
177
+ teamId: pattern.teamId,
178
+ title: pattern.title,
179
+ slug: pattern.slug,
180
+ blocks: Array.isArray(pattern.blocks) ? pattern.blocks : [],
181
+ status: pattern.status,
182
+ description: pattern.description ?? undefined,
183
+ createdAt: pattern.createdAt,
184
+ updatedAt: pattern.updatedAt
185
+ }
186
+ } catch (error) {
187
+ console.error('[PatternsService.getBySlug] Error:', error)
188
+ throw new Error(
189
+ error instanceof Error ? error.message : 'Failed to fetch pattern by slug'
190
+ )
191
+ }
192
+ }
193
+
194
+ /**
195
+ * List patterns with pagination and filtering
196
+ *
197
+ * @param userId - Current user ID for RLS
198
+ * @param teamId - Team ID
199
+ * @param options - List options (limit, offset, status, orderBy, orderDir)
200
+ * @returns Object with patterns array and total count
201
+ *
202
+ * @example
203
+ * const { data, total } = await PatternsService.list(userId, teamId, {
204
+ * status: 'published',
205
+ * limit: 10
206
+ * })
207
+ */
208
+ static async list(
209
+ userId: string,
210
+ teamId: string,
211
+ options: PatternListOptions = {}
212
+ ): Promise<PatternListResult> {
213
+ try {
214
+ if (!userId?.trim()) {
215
+ throw new Error('User ID is required for authentication')
216
+ }
217
+
218
+ if (!teamId?.trim()) {
219
+ throw new Error('Team ID is required')
220
+ }
221
+
222
+ const {
223
+ limit = 20,
224
+ offset = 0,
225
+ status,
226
+ orderBy = 'createdAt',
227
+ orderDir = 'desc'
228
+ } = options
229
+
230
+ // Build WHERE clause
231
+ const conditions: string[] = ['"teamId" = $1']
232
+ const params: unknown[] = [teamId]
233
+ let paramIndex = 2
234
+
235
+ if (status) {
236
+ conditions.push(`status = $${paramIndex}`)
237
+ params.push(status)
238
+ paramIndex++
239
+ }
240
+
241
+ const whereClause = `WHERE ${conditions.join(' AND ')}`
242
+
243
+ // Validate orderBy to prevent SQL injection
244
+ const validOrderBy = ['title', 'slug', 'status', 'createdAt', 'updatedAt'].includes(orderBy)
245
+ ? orderBy
246
+ : 'createdAt'
247
+ const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
248
+
249
+ // Map field names to database columns
250
+ const orderColumnMap: Record<string, string> = {
251
+ title: 'title',
252
+ slug: 'slug',
253
+ status: 'status',
254
+ createdAt: '"createdAt"',
255
+ updatedAt: '"updatedAt"'
256
+ }
257
+ const orderColumn = orderColumnMap[validOrderBy] || '"createdAt"'
258
+
259
+ // Get total count
260
+ const countResult = await queryWithRLS<{ count: string }>(
261
+ `SELECT COUNT(*)::text as count FROM "patterns" ${whereClause}`,
262
+ params,
263
+ userId
264
+ )
265
+ const total = parseInt(countResult[0]?.count || '0', 10)
266
+
267
+ // Get patterns
268
+ params.push(limit, offset)
269
+ const patterns = await queryWithRLS<DbPattern>(
270
+ `
271
+ SELECT
272
+ id,
273
+ "userId",
274
+ "teamId",
275
+ title,
276
+ slug,
277
+ blocks,
278
+ status,
279
+ description,
280
+ "createdAt",
281
+ "updatedAt"
282
+ FROM "patterns"
283
+ ${whereClause}
284
+ ORDER BY ${orderColumn} ${validOrderDir}
285
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
286
+ `,
287
+ params,
288
+ userId
289
+ )
290
+
291
+ return {
292
+ data: patterns.map((pattern) => ({
293
+ id: pattern.id,
294
+ userId: pattern.userId,
295
+ teamId: pattern.teamId,
296
+ title: pattern.title,
297
+ slug: pattern.slug,
298
+ blocks: Array.isArray(pattern.blocks) ? pattern.blocks : [],
299
+ status: pattern.status,
300
+ description: pattern.description ?? undefined,
301
+ createdAt: pattern.createdAt,
302
+ updatedAt: pattern.updatedAt
303
+ })),
304
+ total
305
+ }
306
+ } catch (error) {
307
+ console.error('[PatternsService.list] Error:', error)
308
+ throw new Error(
309
+ error instanceof Error ? error.message : 'Failed to list patterns'
310
+ )
311
+ }
312
+ }
313
+
314
+ /**
315
+ * List only published patterns for a team
316
+ *
317
+ * Convenience method to fetch all published patterns.
318
+ * Used by the block picker to show available patterns for insertion.
319
+ *
320
+ * @param userId - Current user ID for RLS
321
+ * @param teamId - Team ID
322
+ * @returns Array of published patterns
323
+ *
324
+ * @example
325
+ * const publishedPatterns = await PatternsService.listPublished(userId, teamId)
326
+ */
327
+ static async listPublished(
328
+ userId: string,
329
+ teamId: string
330
+ ): Promise<Pattern[]> {
331
+ try {
332
+ const { data } = await this.list(userId, teamId, {
333
+ status: 'published',
334
+ limit: 1000, // Large limit to get all published patterns
335
+ orderBy: 'title',
336
+ orderDir: 'asc'
337
+ })
338
+
339
+ return data
340
+ } catch (error) {
341
+ console.error('[PatternsService.listPublished] Error:', error)
342
+ throw new Error(
343
+ error instanceof Error ? error.message : 'Failed to fetch published patterns'
344
+ )
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Get multiple patterns by IDs (batch fetch)
350
+ *
351
+ * Used for resolving pattern references in pages.
352
+ * Efficiently fetches all referenced patterns in a single query.
353
+ *
354
+ * @param ids - Array of pattern IDs
355
+ * @param userId - Current user ID for RLS
356
+ * @returns Array of patterns (may be fewer than ids if some not found)
357
+ *
358
+ * @example
359
+ * const patternRefs = [{ ref: 'uuid-1' }, { ref: 'uuid-2' }]
360
+ * const patterns = await PatternsService.getByIds(
361
+ * patternRefs.map(p => p.ref),
362
+ * userId
363
+ * )
364
+ */
365
+ static async getByIds(
366
+ ids: string[],
367
+ userId: string
368
+ ): Promise<Pattern[]> {
369
+ try {
370
+ if (!Array.isArray(ids) || ids.length === 0) {
371
+ return []
372
+ }
373
+
374
+ if (!userId?.trim()) {
375
+ throw new Error('User ID is required for authentication')
376
+ }
377
+
378
+ // Filter out empty IDs
379
+ const validIds = ids.filter(id => id && id.trim())
380
+ if (validIds.length === 0) {
381
+ return []
382
+ }
383
+
384
+ // Build WHERE IN clause
385
+ const placeholders = validIds.map((_, i) => `$${i + 1}`).join(', ')
386
+
387
+ const patterns = await queryWithRLS<DbPattern>(
388
+ `
389
+ SELECT
390
+ id,
391
+ "userId",
392
+ "teamId",
393
+ title,
394
+ slug,
395
+ blocks,
396
+ status,
397
+ description,
398
+ "createdAt",
399
+ "updatedAt"
400
+ FROM "patterns"
401
+ WHERE id IN (${placeholders})
402
+ `,
403
+ validIds,
404
+ userId
405
+ )
406
+
407
+ return patterns.map((pattern) => ({
408
+ id: pattern.id,
409
+ userId: pattern.userId,
410
+ teamId: pattern.teamId,
411
+ title: pattern.title,
412
+ slug: pattern.slug,
413
+ blocks: Array.isArray(pattern.blocks) ? pattern.blocks : [],
414
+ status: pattern.status,
415
+ description: pattern.description ?? undefined,
416
+ createdAt: pattern.createdAt,
417
+ updatedAt: pattern.updatedAt
418
+ }))
419
+ } catch (error) {
420
+ console.error('[PatternsService.getByIds] Error:', error)
421
+ throw new Error(
422
+ error instanceof Error ? error.message : 'Failed to fetch patterns by IDs'
423
+ )
424
+ }
425
+ }
426
+
427
+ // ============================================
428
+ // WRITE OPERATIONS
429
+ // ============================================
430
+
431
+ /**
432
+ * Create a new pattern
433
+ *
434
+ * @param userId - Current user ID (will be pattern owner)
435
+ * @param teamId - Team ID (pattern belongs to team)
436
+ * @param data - Pattern data
437
+ * @returns Created pattern
438
+ *
439
+ * @example
440
+ * const pattern = await PatternsService.create(userId, teamId, {
441
+ * title: 'Newsletter CTA',
442
+ * slug: 'newsletter-cta',
443
+ * blocks: [...],
444
+ * status: 'draft'
445
+ * })
446
+ */
447
+ static async create(
448
+ userId: string,
449
+ teamId: string,
450
+ data: CreatePatternInput
451
+ ): Promise<Pattern> {
452
+ try {
453
+ if (!userId?.trim()) {
454
+ throw new Error('User ID is required')
455
+ }
456
+
457
+ if (!teamId?.trim()) {
458
+ throw new Error('Team ID is required')
459
+ }
460
+
461
+ if (!data.title?.trim()) {
462
+ throw new Error('Title is required')
463
+ }
464
+
465
+ if (!data.slug?.trim()) {
466
+ throw new Error('Slug is required')
467
+ }
468
+
469
+ // Validate: patterns cannot contain other patterns (prevent nesting)
470
+ if (data.blocks && containsPatternReference(data.blocks)) {
471
+ throw new Error('Patterns cannot contain other patterns')
472
+ }
473
+
474
+ const result = await mutateWithRLS<DbPattern>(
475
+ `
476
+ INSERT INTO "patterns" (
477
+ "userId",
478
+ "teamId",
479
+ title,
480
+ slug,
481
+ blocks,
482
+ status,
483
+ description
484
+ )
485
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
486
+ RETURNING
487
+ id,
488
+ "userId",
489
+ "teamId",
490
+ title,
491
+ slug,
492
+ blocks,
493
+ status,
494
+ description,
495
+ "createdAt",
496
+ "updatedAt"
497
+ `,
498
+ [
499
+ userId,
500
+ teamId,
501
+ data.title,
502
+ data.slug,
503
+ JSON.stringify(data.blocks || []),
504
+ data.status || 'draft',
505
+ data.description || null
506
+ ],
507
+ userId
508
+ )
509
+
510
+ if (!result || result.rows.length === 0) {
511
+ throw new Error('Failed to create pattern')
512
+ }
513
+
514
+ const pattern = result.rows[0]
515
+
516
+ return {
517
+ id: pattern.id,
518
+ userId: pattern.userId,
519
+ teamId: pattern.teamId,
520
+ title: pattern.title,
521
+ slug: pattern.slug,
522
+ blocks: Array.isArray(pattern.blocks) ? pattern.blocks : [],
523
+ status: pattern.status,
524
+ description: pattern.description ?? undefined,
525
+ createdAt: pattern.createdAt,
526
+ updatedAt: pattern.updatedAt
527
+ }
528
+ } catch (error) {
529
+ console.error('[PatternsService.create] Error:', error)
530
+ throw new Error(
531
+ error instanceof Error ? error.message : 'Failed to create pattern'
532
+ )
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Update an existing pattern
538
+ *
539
+ * @param id - Pattern ID
540
+ * @param userId - Current user ID for RLS
541
+ * @param data - Pattern data to update
542
+ * @returns Updated pattern
543
+ *
544
+ * @example
545
+ * const pattern = await PatternsService.update('pattern-id', userId, {
546
+ * title: 'Updated Title',
547
+ * status: 'published'
548
+ * })
549
+ */
550
+ static async update(
551
+ id: string,
552
+ userId: string,
553
+ data: UpdatePatternInput
554
+ ): Promise<Pattern> {
555
+ try {
556
+ if (!id?.trim()) {
557
+ throw new Error('Pattern ID is required')
558
+ }
559
+
560
+ if (!userId?.trim()) {
561
+ throw new Error('User ID is required')
562
+ }
563
+
564
+ // Validate: patterns cannot contain other patterns (prevent nesting)
565
+ if (data.blocks !== undefined && containsPatternReference(data.blocks)) {
566
+ throw new Error('Patterns cannot contain other patterns')
567
+ }
568
+
569
+ // Build SET clause dynamically
570
+ const updates: string[] = []
571
+ const params: unknown[] = []
572
+ let paramIndex = 1
573
+
574
+ if (data.title !== undefined) {
575
+ updates.push(`title = $${paramIndex}`)
576
+ params.push(data.title)
577
+ paramIndex++
578
+ }
579
+
580
+ if (data.slug !== undefined) {
581
+ updates.push(`slug = $${paramIndex}`)
582
+ params.push(data.slug)
583
+ paramIndex++
584
+ }
585
+
586
+ if (data.blocks !== undefined) {
587
+ updates.push(`blocks = $${paramIndex}`)
588
+ params.push(JSON.stringify(data.blocks))
589
+ paramIndex++
590
+ }
591
+
592
+ if (data.status !== undefined) {
593
+ updates.push(`status = $${paramIndex}`)
594
+ params.push(data.status)
595
+ paramIndex++
596
+ }
597
+
598
+ if (data.description !== undefined) {
599
+ updates.push(`description = $${paramIndex}`)
600
+ params.push(data.description || null)
601
+ paramIndex++
602
+ }
603
+
604
+ if (updates.length === 0) {
605
+ throw new Error('No fields to update')
606
+ }
607
+
608
+ params.push(id)
609
+
610
+ const result = await mutateWithRLS<DbPattern>(
611
+ `
612
+ UPDATE "patterns"
613
+ SET ${updates.join(', ')}
614
+ WHERE id = $${paramIndex}
615
+ RETURNING
616
+ id,
617
+ "userId",
618
+ "teamId",
619
+ title,
620
+ slug,
621
+ blocks,
622
+ status,
623
+ description,
624
+ "createdAt",
625
+ "updatedAt"
626
+ `,
627
+ params,
628
+ userId
629
+ )
630
+
631
+ if (!result || result.rows.length === 0) {
632
+ throw new Error('Pattern not found or not authorized')
633
+ }
634
+
635
+ const pattern = result.rows[0]
636
+
637
+ return {
638
+ id: pattern.id,
639
+ userId: pattern.userId,
640
+ teamId: pattern.teamId,
641
+ title: pattern.title,
642
+ slug: pattern.slug,
643
+ blocks: Array.isArray(pattern.blocks) ? pattern.blocks : [],
644
+ status: pattern.status,
645
+ description: pattern.description ?? undefined,
646
+ createdAt: pattern.createdAt,
647
+ updatedAt: pattern.updatedAt
648
+ }
649
+ } catch (error) {
650
+ console.error('[PatternsService.update] Error:', error)
651
+ throw new Error(
652
+ error instanceof Error ? error.message : 'Failed to update pattern'
653
+ )
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Delete a pattern
659
+ *
660
+ * Uses "Lazy Cleanup" strategy: patterns can be deleted even if in use.
661
+ * - ON DELETE CASCADE cleans up pattern_usages
662
+ * - Orphaned PatternReferences in entities are filtered out on next save
663
+ * - Public rendering gracefully skips missing patterns
664
+ *
665
+ * @param id - Pattern ID
666
+ * @param userId - Current user ID for RLS
667
+ * @returns True if deleted successfully
668
+ *
669
+ * @example
670
+ * await PatternsService.delete('pattern-id', userId)
671
+ */
672
+ static async delete(
673
+ id: string,
674
+ userId: string
675
+ ): Promise<boolean> {
676
+ try {
677
+ if (!id?.trim()) {
678
+ throw new Error('Pattern ID is required')
679
+ }
680
+
681
+ if (!userId?.trim()) {
682
+ throw new Error('User ID is required')
683
+ }
684
+
685
+ // Informative: Log usage count for auditing
686
+ // Uses dynamic import to avoid circular dependency
687
+ try {
688
+ const { PatternUsageService } = await import('../../lib/services/pattern-usage.service')
689
+ const usageCount = await PatternUsageService.getUsageCount(id, userId)
690
+ if (usageCount > 0) {
691
+ console.info(
692
+ `[PatternsService.delete] Deleting pattern ${id} with ${usageCount} active usages. ` +
693
+ `References will be cleaned up on next entity save.`
694
+ )
695
+ }
696
+ } catch {
697
+ // Don't fail delete if usage count check fails
698
+ }
699
+
700
+ const result = await mutateWithRLS<DbPattern>(
701
+ `
702
+ DELETE FROM "patterns"
703
+ WHERE id = $1
704
+ RETURNING id
705
+ `,
706
+ [id],
707
+ userId
708
+ )
709
+
710
+ return result && result.rows.length > 0
711
+ } catch (error) {
712
+ console.error('[PatternsService.delete] Error:', error)
713
+ throw new Error(
714
+ error instanceof Error ? error.message : 'Failed to delete pattern'
715
+ )
716
+ }
717
+ }
718
+ }