@prmichaelsen/acp-visualizer 0.1.0

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 (180) hide show
  1. package/README.md +68 -0
  2. package/agent/commands/acp.clarification-address.md +417 -0
  3. package/agent/commands/acp.clarification-capture.md +386 -0
  4. package/agent/commands/acp.clarification-create.md +437 -0
  5. package/agent/commands/acp.clarifications-research.md +326 -0
  6. package/agent/commands/acp.command-create.md +432 -0
  7. package/agent/commands/acp.design-create.md +286 -0
  8. package/agent/commands/acp.design-reference.md +355 -0
  9. package/agent/commands/acp.handoff.md +270 -0
  10. package/agent/commands/acp.index.md +423 -0
  11. package/agent/commands/acp.init.md +546 -0
  12. package/agent/commands/acp.package-create.md +895 -0
  13. package/agent/commands/acp.package-info.md +212 -0
  14. package/agent/commands/acp.package-install.md +539 -0
  15. package/agent/commands/acp.package-list.md +280 -0
  16. package/agent/commands/acp.package-publish.md +541 -0
  17. package/agent/commands/acp.package-remove.md +293 -0
  18. package/agent/commands/acp.package-search.md +307 -0
  19. package/agent/commands/acp.package-update.md +361 -0
  20. package/agent/commands/acp.package-validate.md +540 -0
  21. package/agent/commands/acp.pattern-create.md +386 -0
  22. package/agent/commands/acp.plan.md +587 -0
  23. package/agent/commands/acp.proceed.md +882 -0
  24. package/agent/commands/acp.project-create.md +675 -0
  25. package/agent/commands/acp.project-info.md +312 -0
  26. package/agent/commands/acp.project-list.md +226 -0
  27. package/agent/commands/acp.project-remove.md +379 -0
  28. package/agent/commands/acp.project-set.md +227 -0
  29. package/agent/commands/acp.project-update.md +307 -0
  30. package/agent/commands/acp.projects-restore.md +228 -0
  31. package/agent/commands/acp.projects-sync.md +347 -0
  32. package/agent/commands/acp.report.md +407 -0
  33. package/agent/commands/acp.resume.md +239 -0
  34. package/agent/commands/acp.sessions.md +301 -0
  35. package/agent/commands/acp.status.md +293 -0
  36. package/agent/commands/acp.sync.md +364 -0
  37. package/agent/commands/acp.task-create.md +500 -0
  38. package/agent/commands/acp.update.md +302 -0
  39. package/agent/commands/acp.validate.md +466 -0
  40. package/agent/commands/acp.version-check-for-updates.md +276 -0
  41. package/agent/commands/acp.version-check.md +191 -0
  42. package/agent/commands/acp.version-update.md +289 -0
  43. package/agent/commands/command.template.md +339 -0
  44. package/agent/commands/git.commit.md +526 -0
  45. package/agent/commands/git.init.md +514 -0
  46. package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
  47. package/agent/commands/tanstack-cloudflare.tail.md +275 -0
  48. package/agent/design/.gitkeep +0 -0
  49. package/agent/design/design.template.md +154 -0
  50. package/agent/design/local.dashboard-layout-routing.md +288 -0
  51. package/agent/design/local.data-model-yaml-parsing.md +310 -0
  52. package/agent/design/local.search-filtering.md +331 -0
  53. package/agent/design/local.server-api-auto-refresh.md +235 -0
  54. package/agent/design/local.table-tree-views.md +299 -0
  55. package/agent/design/local.visualizer-requirements.md +349 -0
  56. package/agent/design/requirements.template.md +387 -0
  57. package/agent/index/.gitkeep +0 -0
  58. package/agent/index/acp.core.yaml +137 -0
  59. package/agent/index/local.main.template.yaml +37 -0
  60. package/agent/manifest.template.yaml +13 -0
  61. package/agent/manifest.yaml +302 -0
  62. package/agent/milestones/.gitkeep +0 -0
  63. package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
  64. package/agent/milestones/milestone-1-{title}.template.md +206 -0
  65. package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
  66. package/agent/package.template.yaml +86 -0
  67. package/agent/patterns/.gitkeep +0 -0
  68. package/agent/patterns/bootstrap.template.md +1237 -0
  69. package/agent/patterns/pattern.template.md +382 -0
  70. package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
  71. package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
  72. package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
  73. package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
  74. package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
  75. package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
  76. package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
  77. package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
  78. package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
  79. package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
  80. package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
  81. package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
  82. package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
  83. package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
  84. package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
  85. package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
  86. package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
  87. package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
  88. package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
  89. package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
  90. package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
  91. package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
  92. package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
  93. package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
  94. package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
  95. package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
  96. package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
  97. package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
  98. package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
  99. package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
  100. package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
  101. package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
  102. package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
  103. package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
  104. package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
  105. package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
  106. package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
  107. package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
  108. package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
  109. package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
  110. package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
  111. package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
  112. package/agent/progress.template.yaml +161 -0
  113. package/agent/progress.yaml +145 -0
  114. package/agent/schemas/package.schema.yaml +276 -0
  115. package/agent/scripts/acp.common.sh +1781 -0
  116. package/agent/scripts/acp.install.sh +333 -0
  117. package/agent/scripts/acp.package-create.sh +924 -0
  118. package/agent/scripts/acp.package-info.sh +288 -0
  119. package/agent/scripts/acp.package-install.sh +893 -0
  120. package/agent/scripts/acp.package-list.sh +311 -0
  121. package/agent/scripts/acp.package-publish.sh +420 -0
  122. package/agent/scripts/acp.package-remove.sh +348 -0
  123. package/agent/scripts/acp.package-search.sh +156 -0
  124. package/agent/scripts/acp.package-update.sh +517 -0
  125. package/agent/scripts/acp.package-validate.sh +1018 -0
  126. package/agent/scripts/acp.uninstall.sh +85 -0
  127. package/agent/scripts/acp.version-check-for-updates.sh +98 -0
  128. package/agent/scripts/acp.version-check.sh +47 -0
  129. package/agent/scripts/acp.version-update.sh +176 -0
  130. package/agent/scripts/acp.yaml-parser.sh +985 -0
  131. package/agent/scripts/acp.yaml-validate.sh +205 -0
  132. package/agent/tasks/.gitkeep +0 -0
  133. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
  134. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
  135. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
  136. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
  137. package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
  138. package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
  139. package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
  140. package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
  141. package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
  142. package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
  143. package/agent/tasks/task-1-{title}.template.md +244 -0
  144. package/bin/visualize.mjs +84 -0
  145. package/package.json +48 -0
  146. package/src/components/ExtraFieldsBadge.tsx +15 -0
  147. package/src/components/FilterBar.tsx +33 -0
  148. package/src/components/Header.tsx +23 -0
  149. package/src/components/MilestoneTable.tsx +167 -0
  150. package/src/components/MilestoneTree.tsx +84 -0
  151. package/src/components/ProgressBar.tsx +20 -0
  152. package/src/components/SearchInput.tsx +22 -0
  153. package/src/components/Sidebar.tsx +54 -0
  154. package/src/components/StatusBadge.tsx +23 -0
  155. package/src/components/StatusDot.tsx +12 -0
  156. package/src/components/TaskList.tsx +36 -0
  157. package/src/components/ViewToggle.tsx +31 -0
  158. package/src/lib/config.ts +8 -0
  159. package/src/lib/file-watcher.ts +43 -0
  160. package/src/lib/search.ts +48 -0
  161. package/src/lib/types.ts +73 -0
  162. package/src/lib/useAutoRefresh.ts +31 -0
  163. package/src/lib/useCollapse.ts +31 -0
  164. package/src/lib/useFilteredData.ts +55 -0
  165. package/src/lib/yaml-loader-real.spec.ts +47 -0
  166. package/src/lib/yaml-loader.spec.ts +201 -0
  167. package/src/lib/yaml-loader.ts +265 -0
  168. package/src/routeTree.gen.ts +140 -0
  169. package/src/router.tsx +10 -0
  170. package/src/routes/__root.tsx +75 -0
  171. package/src/routes/api/watch.ts +29 -0
  172. package/src/routes/index.tsx +115 -0
  173. package/src/routes/milestones.tsx +50 -0
  174. package/src/routes/search.tsx +84 -0
  175. package/src/routes/tasks.tsx +63 -0
  176. package/src/services/progress-database.service.ts +46 -0
  177. package/src/styles.css +25 -0
  178. package/tsconfig.json +24 -0
  179. package/vite.config.ts +16 -0
  180. package/vitest.config.ts +27 -0
@@ -0,0 +1,431 @@
1
+ # Email Service Pattern
2
+
3
+ **Category**: Architecture
4
+ **Applicable To**: TanStack Start + Cloudflare Workers applications that send transactional emails
5
+ **Status**: Stable
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ This pattern provides a lightweight, provider-agnostic email service for sending transactional emails (notifications, digests, confirmations, password resets) from Cloudflare Workers. It wraps email API providers (Mandrill, SendGrid, Resend, etc.) behind a simple `sendEmail({ to, subject, html })` interface and includes HTML template builders for common email types.
12
+
13
+ The pattern enforces non-blocking email sending — email failures are logged but never crash the calling operation. A user creating a post should never fail because the notification email couldn't be sent.
14
+
15
+ ---
16
+
17
+ ## When to Use This Pattern
18
+
19
+ ✅ **Use this pattern when:**
20
+ - Need to send transactional emails (confirmations, notifications, digests)
21
+ - Using a third-party email API (Mandrill, SendGrid, Resend, Mailgun)
22
+ - Want a simple, mockable email interface
23
+ - Need HTML email templates
24
+
25
+ ❌ **Don't use this pattern when:**
26
+ - Only sending emails via a marketing platform (Mailchimp campaigns)
27
+ - Using a full email framework (Nodemailer with SMTP — not available on Workers)
28
+ - Email is not a feature of your application
29
+
30
+ ---
31
+
32
+ ## Core Principles
33
+
34
+ 1. **Simple Interface**: `sendEmail({ to, subject, html })` — nothing more
35
+ 2. **Non-Blocking**: Email failures logged but never thrown — don't crash user flows
36
+ 3. **Provider-Agnostic**: Wrap any email API behind the same interface
37
+ 4. **Template Builders**: Separate functions build HTML content
38
+ 5. **Server-Side Only**: Email sending only from API routes, cron jobs, and server functions
39
+ 6. **Secrets via Environment**: API keys stored as Cloudflare secrets, never hardcoded
40
+
41
+ ---
42
+
43
+ ## Implementation
44
+
45
+ ### Structure
46
+
47
+ ```
48
+ src/lib/
49
+ ├── email/
50
+ │ ├── send-email.ts # Core send function
51
+ │ ├── templates/
52
+ │ │ ├── base.ts # Base HTML wrapper
53
+ │ │ ├── welcome.ts # Welcome email template
54
+ │ │ ├── daily-digest.ts # Daily digest template
55
+ │ │ ├── appointment.ts # Appointment notification
56
+ │ │ └── password-reset.ts # Password reset template
57
+ │ └── index.ts # Barrel export
58
+ ```
59
+
60
+ ### Code Example
61
+
62
+ #### Step 1: Core Send Function
63
+
64
+ ```typescript
65
+ // src/lib/email/send-email.ts
66
+
67
+ const MANDRILL_API_URL = 'https://mandrillapp.com/api/1.0/messages/send'
68
+
69
+ interface SendEmailParams {
70
+ to: string | string[]
71
+ subject: string
72
+ html: string
73
+ from?: string
74
+ fromName?: string
75
+ }
76
+
77
+ interface SendEmailResult {
78
+ success: boolean
79
+ error?: string
80
+ }
81
+
82
+ /**
83
+ * Send a transactional email via Mandrill.
84
+ * Non-blocking: logs errors but never throws.
85
+ */
86
+ export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
87
+ const apiKey = process.env.MANDRILL_API_KEY
88
+
89
+ if (!apiKey) {
90
+ console.error('[Email] Mandrill API key not configured')
91
+ return { success: false, error: 'API key not configured' }
92
+ }
93
+
94
+ const recipients = Array.isArray(params.to)
95
+ ? params.to.map(email => ({ email, type: 'to' as const }))
96
+ : [{ email: params.to, type: 'to' as const }]
97
+
98
+ try {
99
+ const response = await fetch(MANDRILL_API_URL, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ key: apiKey,
104
+ message: {
105
+ html: params.html,
106
+ subject: params.subject,
107
+ from_email: params.from || 'noreply@example.com',
108
+ from_name: params.fromName || 'My App',
109
+ to: recipients,
110
+ important: true,
111
+ track_opens: true,
112
+ track_clicks: true,
113
+ auto_text: true,
114
+ },
115
+ }),
116
+ })
117
+
118
+ if (!response.ok) {
119
+ const error = await response.text()
120
+ console.error('[Email] Send failed:', error)
121
+ return { success: false, error }
122
+ }
123
+
124
+ const data = await response.json()
125
+ console.log(`[Email] Sent "${params.subject}" to ${recipients.length} recipient(s)`)
126
+ return { success: true }
127
+ } catch (error) {
128
+ console.error('[Email] Send error:', error)
129
+ return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
130
+ }
131
+ }
132
+ ```
133
+
134
+ #### Step 2: Base HTML Template
135
+
136
+ ```typescript
137
+ // src/lib/email/templates/base.ts
138
+
139
+ interface BaseTemplateParams {
140
+ title: string
141
+ body: string
142
+ footerText?: string
143
+ }
144
+
145
+ /**
146
+ * Base HTML email wrapper with consistent styling
147
+ */
148
+ export function baseTemplate({ title, body, footerText }: BaseTemplateParams): string {
149
+ return `<!DOCTYPE html>
150
+ <html>
151
+ <head>
152
+ <meta charset="utf-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
+ <title>${title}</title>
155
+ <style>
156
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f5f5f5; }
157
+ .container { max-width: 600px; margin: 0 auto; padding: 20px; }
158
+ .card { background: #ffffff; border-radius: 8px; padding: 32px; margin: 20px 0; }
159
+ .header { text-align: center; padding-bottom: 20px; border-bottom: 1px solid #eee; margin-bottom: 20px; }
160
+ .footer { text-align: center; color: #888; font-size: 12px; padding: 20px 0; }
161
+ h1 { color: #333; font-size: 24px; margin: 0; }
162
+ p { color: #555; line-height: 1.6; }
163
+ .btn { display: inline-block; background: #3b82f6; color: #fff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; }
164
+ </style>
165
+ </head>
166
+ <body>
167
+ <div class="container">
168
+ <div class="card">
169
+ <div class="header">
170
+ <h1>${title}</h1>
171
+ </div>
172
+ ${body}
173
+ </div>
174
+ <div class="footer">
175
+ ${footerText || 'You are receiving this because you have an account with us.'}
176
+ </div>
177
+ </div>
178
+ </body>
179
+ </html>`
180
+ }
181
+ ```
182
+
183
+ #### Step 3: Domain-Specific Template
184
+
185
+ ```typescript
186
+ // src/lib/email/templates/daily-digest.ts
187
+
188
+ import { baseTemplate } from './base'
189
+
190
+ interface DigestData {
191
+ checkIns: { guestName: string; property: string }[]
192
+ checkOuts: { guestName: string; property: string }[]
193
+ unclaimedCleans: { property: string; date: string }[]
194
+ }
195
+
196
+ export function dailyDigestTemplate(data: DigestData): string {
197
+ const sections: string[] = []
198
+
199
+ if (data.checkIns.length > 0) {
200
+ sections.push(`
201
+ <h2>Check-Ins Today (${data.checkIns.length})</h2>
202
+ <ul>
203
+ ${data.checkIns.map(c => `<li><strong>${c.guestName}</strong> at ${c.property}</li>`).join('')}
204
+ </ul>
205
+ `)
206
+ }
207
+
208
+ if (data.checkOuts.length > 0) {
209
+ sections.push(`
210
+ <h2>Check-Outs Today (${data.checkOuts.length})</h2>
211
+ <ul>
212
+ ${data.checkOuts.map(c => `<li><strong>${c.guestName}</strong> at ${c.property}</li>`).join('')}
213
+ </ul>
214
+ `)
215
+ }
216
+
217
+ if (data.unclaimedCleans.length > 0) {
218
+ sections.push(`
219
+ <h2>Unclaimed Cleans (${data.unclaimedCleans.length})</h2>
220
+ <ul>
221
+ ${data.unclaimedCleans.map(c => `<li>${c.property} — ${c.date}</li>`).join('')}
222
+ </ul>
223
+ `)
224
+ }
225
+
226
+ if (sections.length === 0) {
227
+ sections.push('<p>No activity to report today.</p>')
228
+ }
229
+
230
+ return baseTemplate({
231
+ title: `Daily Digest — ${new Date().toLocaleDateString()}`,
232
+ body: sections.join(''),
233
+ })
234
+ }
235
+ ```
236
+
237
+ #### Step 4: Use in Application Code
238
+
239
+ ```typescript
240
+ // src/lib/scheduled/daily-digest.ts
241
+
242
+ import { sendEmail } from '@/lib/email'
243
+ import { dailyDigestTemplate } from '@/lib/email/templates/daily-digest'
244
+
245
+ export async function handleDailyDigest(): Promise<void> {
246
+ const checkIns = await ReservationDatabaseService.getTodayCheckIns()
247
+ const checkOuts = await ReservationDatabaseService.getTodayCheckOuts()
248
+ const unclaimedCleans = await AppointmentDatabaseService.getUnclaimedNextWeek()
249
+
250
+ const html = dailyDigestTemplate({ checkIns, checkOuts, unclaimedCleans })
251
+
252
+ await sendEmail({
253
+ to: ['manager@example.com', 'ops@example.com'],
254
+ subject: `Daily Digest — ${new Date().toLocaleDateString()}`,
255
+ html,
256
+ })
257
+ }
258
+ ```
259
+
260
+ #### Step 5: Use in API Route (Non-Blocking)
261
+
262
+ ```typescript
263
+ // routes/api/posts/create.tsx
264
+
265
+ POST: async ({ request }) => {
266
+ const user = await getAuthSession()
267
+ const body = await request.json()
268
+ const post = await PostDatabaseService.create(user.uid, body)
269
+
270
+ // Send notification email (non-blocking)
271
+ sendEmail({
272
+ to: 'admin@example.com',
273
+ subject: `New post: ${body.title}`,
274
+ html: baseTemplate({
275
+ title: 'New Post Created',
276
+ body: `<p>${user.displayName} created a new post: <strong>${body.title}</strong></p>`,
277
+ }),
278
+ }).catch(error => console.error('[Email] Notification failed:', error))
279
+ // Note: not awaited — fire and forget
280
+
281
+ return new Response(JSON.stringify(post), {
282
+ status: 201,
283
+ headers: { 'Content-Type': 'application/json' },
284
+ })
285
+ }
286
+ ```
287
+
288
+ ---
289
+
290
+ ## Swapping Providers
291
+
292
+ The `sendEmail` function wraps a single provider. To swap providers, change only that file:
293
+
294
+ ### Mandrill → SendGrid
295
+
296
+ ```typescript
297
+ // src/lib/email/send-email.ts (SendGrid version)
298
+ export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
299
+ const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
300
+ method: 'POST',
301
+ headers: {
302
+ 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
303
+ 'Content-Type': 'application/json',
304
+ },
305
+ body: JSON.stringify({
306
+ personalizations: [{ to: [{ email: params.to }] }],
307
+ from: { email: params.from || 'noreply@example.com' },
308
+ subject: params.subject,
309
+ content: [{ type: 'text/html', value: params.html }],
310
+ }),
311
+ })
312
+ // ...
313
+ }
314
+ ```
315
+
316
+ ### Mandrill → Resend
317
+
318
+ ```typescript
319
+ // src/lib/email/send-email.ts (Resend version)
320
+ export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
321
+ const response = await fetch('https://api.resend.com/emails', {
322
+ method: 'POST',
323
+ headers: {
324
+ 'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
325
+ 'Content-Type': 'application/json',
326
+ },
327
+ body: JSON.stringify({
328
+ from: params.from || 'noreply@example.com',
329
+ to: params.to,
330
+ subject: params.subject,
331
+ html: params.html,
332
+ }),
333
+ })
334
+ // ...
335
+ }
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Benefits
341
+
342
+ ### 1. Simple Interface
343
+ `sendEmail({ to, subject, html })` — no provider-specific knowledge needed in calling code.
344
+
345
+ ### 2. Non-Blocking
346
+ Email failures never crash user operations. Fire-and-forget pattern for notifications.
347
+
348
+ ### 3. Provider Swappable
349
+ Change email provider by editing one file. No impact on templates or callers.
350
+
351
+ ### 4. Testable
352
+ `sendEmail` is easily mockable for testing. Templates are pure functions returning strings.
353
+
354
+ ---
355
+
356
+ ## Trade-offs
357
+
358
+ ### 1. HTML Templates as Strings
359
+ **Downside**: Building HTML in template literals is error-prone and hard to preview.
360
+ **Mitigation**: Use the base template for consistent structure. Test templates by rendering in a browser.
361
+
362
+ ### 2. No MJML/React Email
363
+ **Downside**: Not using modern email frameworks (MJML, React Email).
364
+ **Mitigation**: These can be added later. The `sendEmail` interface stays the same — only template builders change.
365
+
366
+ ---
367
+
368
+ ## Anti-Patterns
369
+
370
+ ### ❌ Anti-Pattern: Throwing on Email Failure
371
+
372
+ ```typescript
373
+ // ❌ BAD: Email failure crashes the post creation
374
+ const post = await PostService.create(data)
375
+ await sendEmail({ to, subject, html }) // If this throws, post appears to fail!
376
+ return post
377
+
378
+ // ✅ GOOD: Fire and forget
379
+ const post = await PostService.create(data)
380
+ sendEmail({ to, subject, html }).catch(err => console.error('[Email]', err))
381
+ return post
382
+ ```
383
+
384
+ ### ❌ Anti-Pattern: Inline HTML in Route Handlers
385
+
386
+ ```typescript
387
+ // ❌ BAD: HTML template inline in route
388
+ await sendEmail({
389
+ to: user.email,
390
+ subject: 'Welcome',
391
+ html: `<html><body><h1>Welcome ${user.name}!</h1><p>...</p></body></html>`
392
+ })
393
+
394
+ // ✅ GOOD: Use template function
395
+ import { welcomeTemplate } from '@/lib/email/templates/welcome'
396
+ await sendEmail({
397
+ to: user.email,
398
+ subject: 'Welcome',
399
+ html: welcomeTemplate({ name: user.name })
400
+ })
401
+ ```
402
+
403
+ ---
404
+
405
+ ## Related Patterns
406
+
407
+ - **[Scheduled Tasks](./tanstack-cloudflare.scheduled-tasks.md)**: Cron-triggered emails (daily digests, reminders)
408
+ - **[Third-Party API Integration](./tanstack-cloudflare.third-party-api-integration.md)**: Email provider as an integration
409
+ - **[API Route Handlers](./tanstack-cloudflare.api-route-handlers.md)**: Email sent from API routes
410
+
411
+ ---
412
+
413
+ ## Checklist for Implementation
414
+
415
+ - [ ] `sendEmail()` function with `{ to, subject, html }` interface
416
+ - [ ] Returns `{ success, error? }` — never throws
417
+ - [ ] API key stored as Cloudflare secret
418
+ - [ ] Base HTML template with consistent styling
419
+ - [ ] Domain-specific template functions (digest, notification, etc.)
420
+ - [ ] Templates are pure functions (string input → string output)
421
+ - [ ] Non-critical emails sent fire-and-forget (`.catch()`)
422
+ - [ ] Critical emails awaited but wrapped in try/catch
423
+ - [ ] Multiple recipients supported (string or string array)
424
+ - [ ] Provider can be swapped by editing one file
425
+
426
+ ---
427
+
428
+ **Status**: Stable - Production-ready email service for Cloudflare Workers
429
+ **Recommendation**: Use for all transactional email needs
430
+ **Last Updated**: 2026-02-28
431
+ **Contributors**: Patrick Michaelsen
@@ -0,0 +1,98 @@
1
+ # Expander Component
2
+
3
+ **Category**: Design
4
+ **Applicable To**: Expandable/collapsible sections with smooth height animation and 10 visual variants
5
+ **Status**: Stable
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ A polymorphic expandable section component with 10 distinct visual variants (gradient-glow, neon-accent, glass-float, slide-arrow, border-sweep, stacked-lift, visibility, thread, highlight, segmented). Uses a `useCollapse` hook for CSS-transition-based height animation (300ms cubic-bezier). Controlled open/close state with consistent API across all variants.
12
+
13
+ ---
14
+
15
+ ## Implementation
16
+
17
+ **File**: `src/components/Expander.tsx`
18
+
19
+ ```typescript
20
+ interface ExpanderProps {
21
+ title: string
22
+ count?: number // Optional count badge (border-sweep variant)
23
+ open: boolean // Controlled state
24
+ onToggle: () => void
25
+ children: ReactNode
26
+ }
27
+
28
+ function Expander({ variant = 'gradient-glow', ...props }: ExpanderProps & { variant?: ExpanderVariant })
29
+ ```
30
+
31
+ ### useCollapse Hook (Height Animation)
32
+
33
+ ```typescript
34
+ function useCollapse(open: boolean) {
35
+ const ref = useRef<HTMLDivElement>(null)
36
+ const [height, setHeight] = useState<number | undefined>(open ? undefined : 0)
37
+
38
+ useEffect(() => {
39
+ const el = ref.current
40
+ if (!el) return
41
+ if (open) {
42
+ setHeight(el.scrollHeight)
43
+ const id = setTimeout(() => setHeight(undefined), 300) // Auto after animation
44
+ return () => clearTimeout(id)
45
+ } else {
46
+ setHeight(el.scrollHeight)
47
+ requestAnimationFrame(() => requestAnimationFrame(() => setHeight(0)))
48
+ }
49
+ }, [open])
50
+
51
+ return {
52
+ ref,
53
+ style: {
54
+ height: height != null ? `${height}px` : 'auto',
55
+ overflow: 'hidden' as const,
56
+ transition: 'height 300ms cubic-bezier(0.4, 0, 0.2, 1)',
57
+ },
58
+ }
59
+ }
60
+ ```
61
+
62
+ ### Variants
63
+
64
+ | Variant | Visual |
65
+ |---|---|
66
+ | `gradient-glow` | Blue text title with chevron, minimal |
67
+ | `neon-accent` | Left green border (glows on open), plus/minus icon |
68
+ | `glass-float` | Frosted glass blur effect when open, sparkle icon |
69
+ | `slide-arrow` | Arrow rotates 90° on open, indented content |
70
+ | `border-sweep` | Bottom amber gradient border sweeps in, count badge |
71
+ | `stacked-lift` | Layered border effects above title when open |
72
+ | `visibility` | iOS-style toggle switch, eye/eye-off icon |
73
+ | `thread` | Gradient vertical line with nested indentation |
74
+ | `highlight` | Indigo ring + background highlight, zap icon with scale |
75
+ | `segmented` | 3 animated dots (staggered timing), fuchsia color |
76
+
77
+ Exported as `EXPANDER_VARIANTS: Array<{ id: string; label: string }>`.
78
+
79
+ **Usage**:
80
+
81
+ ```typescript
82
+ const [open, setOpen] = useState(false)
83
+
84
+ <Expander
85
+ variant="glass-float"
86
+ title="Advanced Settings"
87
+ open={open}
88
+ onToggle={() => setOpen(!open)}
89
+ >
90
+ <p>Expanded content here</p>
91
+ </Expander>
92
+ ```
93
+
94
+ ---
95
+
96
+ **Status**: Stable
97
+ **Last Updated**: 2026-03-14
98
+ **Contributors**: Community
@@ -0,0 +1,115 @@
1
+ # FCM Push Notifications
2
+
3
+ **Category**: Architecture
4
+ **Applicable To**: Multi-device push notifications via Firebase Cloud Messaging with automatic stale token cleanup
5
+ **Status**: Stable
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ Server-side FCM push notification delivery to all registered devices for a user. Tokens stored in Firestore per-user with hash-based document IDs for upsert. Invalid/expired tokens (`UNREGISTERED`, `INVALID_ARGUMENT`) are automatically removed on send failure. Integrated with the notification triggers service as the offline fallback when WebSocket is unavailable.
12
+
13
+ ---
14
+
15
+ ## Implementation
16
+
17
+ ### FcmService
18
+
19
+ **File**: `src/services/fcm.service.ts`
20
+
21
+ ```typescript
22
+ interface PushNotificationPayload {
23
+ title: string
24
+ body: string
25
+ data?: Record<string, string>
26
+ }
27
+
28
+ class FcmService {
29
+ static async sendToUser(userId: string, payload: PushNotificationPayload): Promise<number> {
30
+ const tokens = await FcmTokenDatabaseService.getTokens(userId)
31
+ if (tokens.length === 0) return 0
32
+
33
+ let successCount = 0
34
+ await Promise.all(tokens.map(async (token) => {
35
+ try {
36
+ await sendMessage({
37
+ token: token.fcm_token,
38
+ notification: { title: payload.title, body: payload.body },
39
+ data: payload.data,
40
+ })
41
+ successCount++
42
+ } catch (error) {
43
+ if (errMsg.includes('UNREGISTERED') || errMsg.includes('INVALID_ARGUMENT')) {
44
+ await FcmTokenDatabaseService.removeTokenById(userId, token.id) // Auto-cleanup
45
+ }
46
+ }
47
+ }))
48
+ return successCount
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### FcmTokenDatabaseService
54
+
55
+ **File**: `src/services/fcm-token-database.service.ts`
56
+
57
+ ```typescript
58
+ interface FcmToken {
59
+ id: string // Hash of fcm_token
60
+ fcm_token: string
61
+ platform: 'ios' | 'android' | 'web'
62
+ created_at: string
63
+ updated_at: string
64
+ }
65
+ // Collection: users/{userId}/fcm_tokens
66
+ // Document ID: hashToken(fcmToken)
67
+
68
+ class FcmTokenDatabaseService {
69
+ static async upsertToken(userId, fcmToken, platform): Promise<FcmToken>
70
+ static async removeToken(userId, fcmToken): Promise<void>
71
+ static async removeTokenById(userId, tokenId): Promise<void>
72
+ static async getTokens(userId): Promise<FcmToken[]>
73
+ }
74
+ ```
75
+
76
+ ### Client Registration
77
+
78
+ ```typescript
79
+ // POST /api/mobile/register-fcm-token
80
+ // Body: { token: string, platform: 'ios' | 'android' | 'web' }
81
+ await FcmTokenDatabaseService.upsertToken(userId, token, platform)
82
+ ```
83
+
84
+ ### Delivery Strategy (with NotificationTriggers)
85
+
86
+ ```typescript
87
+ // WebSocket-first, FCM-fallback
88
+ if (await NotificationHubService.isUserConnected(env, recipientId)) {
89
+ await NotificationHubService.pushNotification(env, recipientId, notification)
90
+ } else {
91
+ await FcmService.sendToUser(recipientId, { title, body, data })
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Checklist
98
+
99
+ - [ ] Token doc ID is hash of FCM token (enables upsert without duplicates)
100
+ - [ ] `sendToUser` sends to ALL registered tokens (multiple devices)
101
+ - [ ] Invalid tokens auto-removed on UNREGISTERED/INVALID_ARGUMENT errors
102
+ - [ ] Client registers token on app launch / permission grant
103
+ - [ ] Delivery strategy checks WebSocket first, falls back to FCM
104
+
105
+ ---
106
+
107
+ ## Related Patterns
108
+
109
+ - **[Notifications Engine](./tanstack-cloudflare.notifications-engine.md)**: WebSocket-first delivery that FCM falls back from
110
+
111
+ ---
112
+
113
+ **Status**: Stable
114
+ **Last Updated**: 2026-03-14
115
+ **Contributors**: Community