@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.
- package/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- 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
|