create-questpie 2.0.0 → 2.0.2
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 +10 -6
- package/dist/index.mjs +140 -25
- package/package.json +5 -3
- package/skills/questpie/AGENTS.md +2664 -0
- package/skills/questpie/SKILL.md +181 -0
- package/skills/questpie/references/auth.md +121 -0
- package/skills/questpie/references/business-logic.md +550 -0
- package/skills/questpie/references/codegen-plugin-api.md +382 -0
- package/skills/questpie/references/crud-api.md +378 -0
- package/skills/questpie/references/data-modeling.md +489 -0
- package/skills/questpie/references/extend.md +493 -0
- package/skills/questpie/references/field-types.md +386 -0
- package/skills/questpie/references/infrastructure-adapters.md +545 -0
- package/skills/questpie/references/multi-tenancy.md +364 -0
- package/skills/questpie/references/production.md +475 -0
- package/skills/questpie/references/query-operators.md +125 -0
- package/skills/questpie/references/quickstart.md +549 -0
- package/skills/questpie/references/rules.md +327 -0
- package/skills/questpie/references/tanstack-query.md +520 -0
- package/skills/questpie-admin/AGENTS.md +1442 -0
- package/skills/questpie-admin/SKILL.md +410 -0
- package/skills/questpie-admin/references/blocks.md +307 -0
- package/skills/questpie-admin/references/custom-ui.md +305 -0
- package/skills/questpie-admin/references/views.md +433 -0
- package/templates/tanstack-start/AGENTS.md +71 -62
- package/templates/tanstack-start/CLAUDE.md +26 -23
- package/templates/tanstack-start/README.md +32 -20
- package/templates/tanstack-start/env.example +1 -1
- package/templates/tanstack-start/package.json +20 -6
- package/templates/tanstack-start/src/lib/client.ts +2 -2
- package/templates/tanstack-start/src/lib/env.ts +1 -1
- package/templates/tanstack-start/src/questpie/admin/.generated/client.ts +13 -0
- package/templates/tanstack-start/src/questpie/admin/modules.ts +1 -0
- package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +117 -241
- package/templates/tanstack-start/src/questpie/server/.generated/index.ts +129 -81
- package/templates/tanstack-start/src/questpie/server/app.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/config/admin.ts +27 -30
- package/templates/tanstack-start/src/questpie/server/globals/site-settings.global.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/questpie.config.ts +1 -1
- package/templates/tanstack-start/src/routeTree.gen.ts +138 -0
- package/templates/tanstack-start/src/routes/__root.tsx +0 -2
- package/templates/tanstack-start/src/routes/admin.tsx +8 -1
- package/templates/tanstack-start/src/tanstack-start.d.ts +1 -0
- package/templates/tanstack-start/src/vite-env.d.ts +1 -0
- package/templates/tanstack-start/vite.config.ts +1 -3
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: questpie-core-business-logic
|
|
3
|
+
description: QUESTPIE routes jobs services emails route job service email background queue scheduling Zod input validation server-side logic reusable services email templates
|
|
4
|
+
- questpie-core
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# QUESTPIE Business Logic - Routes, Jobs, Services, Emails
|
|
8
|
+
|
|
9
|
+
This skill builds on questpie-core. It covers four business-logic primitives: routes (JSON and raw HTTP), jobs (background tasks), services (reusable logic), and emails (templates).
|
|
10
|
+
|
|
11
|
+
## Routes (JSON)
|
|
12
|
+
|
|
13
|
+
JSON routes are typed server-side endpoints. Define an input schema with Zod, write a handler, and call it from the client with full type safety.
|
|
14
|
+
|
|
15
|
+
### Defining a Route
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// routes/get-active-barbers.ts
|
|
19
|
+
import { route } from "questpie";
|
|
20
|
+
import z from "zod";
|
|
21
|
+
|
|
22
|
+
export default route()
|
|
23
|
+
.post()
|
|
24
|
+
.schema(z.object({}))
|
|
25
|
+
.handler(async ({ collections }) => {
|
|
26
|
+
return await collections.barbers.find({
|
|
27
|
+
where: { isActive: true },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Place files in `routes/`. The filename becomes the route key: `get-active-barbers.ts` maps to `getActiveBarbers`. Files **must** use `export default`.
|
|
33
|
+
|
|
34
|
+
### Input Validation
|
|
35
|
+
|
|
36
|
+
JSON routes validate input with Zod automatically:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// routes/create-booking.ts
|
|
40
|
+
import { route } from "questpie";
|
|
41
|
+
import z from "zod";
|
|
42
|
+
|
|
43
|
+
export default route()
|
|
44
|
+
.post()
|
|
45
|
+
.schema(
|
|
46
|
+
z.object({
|
|
47
|
+
barberId: z.string(),
|
|
48
|
+
serviceId: z.string(),
|
|
49
|
+
scheduledAt: z.string().datetime(),
|
|
50
|
+
customerName: z.string().min(2),
|
|
51
|
+
customerEmail: z.string().email(),
|
|
52
|
+
notes: z.string().optional(),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
.handler(async ({ input, collections }) => {
|
|
56
|
+
const service = await collections.services.findOne({
|
|
57
|
+
where: { id: input.serviceId },
|
|
58
|
+
});
|
|
59
|
+
if (!service) throw new Error("Service not found");
|
|
60
|
+
|
|
61
|
+
const appointment = await collections.appointments.create({
|
|
62
|
+
barber: input.barberId,
|
|
63
|
+
service: input.serviceId,
|
|
64
|
+
scheduledAt: new Date(input.scheduledAt),
|
|
65
|
+
status: "pending",
|
|
66
|
+
notes: input.notes || null,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
success: true,
|
|
71
|
+
appointmentId: appointment.id,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Handler Context
|
|
77
|
+
|
|
78
|
+
Route handlers receive the full `AppContext`:
|
|
79
|
+
|
|
80
|
+
| Property | Description |
|
|
81
|
+
| ------------- | -------------------------------------- |
|
|
82
|
+
| `input` | Validated data matching the Zod schema |
|
|
83
|
+
| `collections` | Typed collection API |
|
|
84
|
+
| `queue` | Publish background jobs |
|
|
85
|
+
| `email` | Send emails |
|
|
86
|
+
| `db` | Raw database access |
|
|
87
|
+
| `session` | Current auth session |
|
|
88
|
+
| `services` | Custom services from `services/` |
|
|
89
|
+
|
|
90
|
+
### Calling Routes
|
|
91
|
+
|
|
92
|
+
From the client SDK:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { client } from "@/lib/client";
|
|
96
|
+
|
|
97
|
+
const result = await client.routes.createBooking({
|
|
98
|
+
barberId: "abc",
|
|
99
|
+
serviceId: "def",
|
|
100
|
+
scheduledAt: "2025-03-15T10:00:00Z",
|
|
101
|
+
customerName: "John",
|
|
102
|
+
customerEmail: "john@example.com",
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Via HTTP: `POST /api/create-booking` with JSON body.
|
|
107
|
+
|
|
108
|
+
### Nested Routes
|
|
109
|
+
|
|
110
|
+
Organize in subdirectories for namespacing:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
routes/
|
|
114
|
+
booking/
|
|
115
|
+
create.ts --> client.routes.booking.create()
|
|
116
|
+
cancel.ts --> client.routes.booking.cancel()
|
|
117
|
+
get-active-barbers.ts --> client.routes.getActiveBarbers()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Jobs
|
|
121
|
+
|
|
122
|
+
Jobs are background tasks that run outside the request lifecycle. Ideal for sending emails, processing data, or any work that should not block an API response.
|
|
123
|
+
|
|
124
|
+
### Defining a Job
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// jobs/send-appointment-confirmation.ts
|
|
128
|
+
import { job } from "questpie";
|
|
129
|
+
import z from "zod";
|
|
130
|
+
|
|
131
|
+
export default job({
|
|
132
|
+
name: "sendAppointmentConfirmation",
|
|
133
|
+
schema: z.object({
|
|
134
|
+
appointmentId: z.string(),
|
|
135
|
+
customerId: z.string(),
|
|
136
|
+
}),
|
|
137
|
+
handler: async ({ payload, email, collections }) => {
|
|
138
|
+
const customer = await collections.user.findOne({
|
|
139
|
+
where: { id: payload.customerId },
|
|
140
|
+
});
|
|
141
|
+
if (!customer) return;
|
|
142
|
+
|
|
143
|
+
const appointment = await collections.appointments.findOne({
|
|
144
|
+
where: { id: payload.appointmentId },
|
|
145
|
+
with: { barber: true, service: true },
|
|
146
|
+
});
|
|
147
|
+
if (!appointment) return;
|
|
148
|
+
|
|
149
|
+
await email.sendTemplate({
|
|
150
|
+
template: "appointmentConfirmation",
|
|
151
|
+
input: {
|
|
152
|
+
customerName: customer.name,
|
|
153
|
+
appointmentId: appointment.id,
|
|
154
|
+
barberName: appointment.barber.name,
|
|
155
|
+
serviceName: appointment.service.name,
|
|
156
|
+
scheduledAt: appointment.scheduledAt.toISOString(),
|
|
157
|
+
},
|
|
158
|
+
to: customer.email,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Place files in `jobs/`. The filename becomes the job key: `send-appointment-confirmation.ts` maps to `sendAppointmentConfirmation`.
|
|
165
|
+
|
|
166
|
+
### Publishing Jobs
|
|
167
|
+
|
|
168
|
+
Publish from hooks, routes, or other jobs via the typed `queue` context:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
.hooks({
|
|
172
|
+
afterChange: async ({ data, operation, queue }) => {
|
|
173
|
+
if (operation === "create") {
|
|
174
|
+
await queue.sendAppointmentConfirmation.publish({
|
|
175
|
+
appointmentId: data.id,
|
|
176
|
+
customerId: data.customer,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `queue` object provides full autocompletion for all jobs and their payloads.
|
|
184
|
+
|
|
185
|
+
### Job Handler Context
|
|
186
|
+
|
|
187
|
+
| Property | Description |
|
|
188
|
+
| ------------- | -------------------------------------- |
|
|
189
|
+
| `payload` | Validated data matching the Zod schema |
|
|
190
|
+
| `collections` | Typed collection API |
|
|
191
|
+
| `email` | Email service |
|
|
192
|
+
| `queue` | Publish other jobs |
|
|
193
|
+
| `db` | Database instance |
|
|
194
|
+
|
|
195
|
+
### Queue Adapter Configuration
|
|
196
|
+
|
|
197
|
+
Configure the queue adapter in your runtime config:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// questpie.config.ts
|
|
201
|
+
import { pgBossAdapter, runtimeConfig } from "questpie";
|
|
202
|
+
|
|
203
|
+
export default runtimeConfig({
|
|
204
|
+
queue: {
|
|
205
|
+
adapter: pgBossAdapter({
|
|
206
|
+
connectionString: process.env.DATABASE_URL,
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Routes
|
|
213
|
+
|
|
214
|
+
Routes give raw HTTP request/response handling for webhooks, OAuth callbacks, health checks, file downloads, and streaming.
|
|
215
|
+
|
|
216
|
+
### Defining a Route
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// routes/health.ts
|
|
220
|
+
import { route } from "questpie";
|
|
221
|
+
|
|
222
|
+
export default route({
|
|
223
|
+
method: "GET",
|
|
224
|
+
handler: async ({ db }) => {
|
|
225
|
+
const healthy = await db
|
|
226
|
+
.execute(sql`SELECT 1`)
|
|
227
|
+
.then(() => true)
|
|
228
|
+
.catch(() => false);
|
|
229
|
+
return Response.json({ status: healthy ? "ok" : "degraded" });
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Place files in `routes/`. The file path maps to a flat URL under your `basePath` (`/api` by default):
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
routes/
|
|
238
|
+
health.ts --> /api/health
|
|
239
|
+
webhooks/
|
|
240
|
+
stripe.ts --> /api/webhooks/stripe
|
|
241
|
+
export.ts --> /api/export
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Route Methods
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
route({ method: "POST", handler: ... }) // Single method
|
|
248
|
+
route({ method: ["GET", "POST"], handler: ... }) // Multiple methods
|
|
249
|
+
route({ handler: ... }) // All methods (default)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Supported: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`.
|
|
253
|
+
|
|
254
|
+
### Route Handler Context
|
|
255
|
+
|
|
256
|
+
| Property | Type | Description |
|
|
257
|
+
| ------------- | ------------------------ | ------------------------ |
|
|
258
|
+
| `request` | `Request` | Standard Web API Request |
|
|
259
|
+
| `params` | `Record<string, string>` | URL path parameters |
|
|
260
|
+
| `locale` | `string` | Current locale |
|
|
261
|
+
| `db` | `Database` | Database instance |
|
|
262
|
+
| `session` | `Session \| null` | Current auth session |
|
|
263
|
+
| `collections` | `CollectionsAPI` | Typed collection API |
|
|
264
|
+
| `queue` | `QueueClient` | Queue client |
|
|
265
|
+
| `email` | `MailerService` | Email service |
|
|
266
|
+
| `services` | | User-defined services |
|
|
267
|
+
|
|
268
|
+
Route handlers must return a `Response` object.
|
|
269
|
+
|
|
270
|
+
### JSON Routes vs Raw Routes
|
|
271
|
+
|
|
272
|
+
| Aspect | JSON route | Raw route |
|
|
273
|
+
| ------------- | ------------------------------- | --------------------------------- |
|
|
274
|
+
| **Transport** | HTTP JSON (`/api/{path}`) | Raw HTTP (`/api/{path}`) |
|
|
275
|
+
| **Input** | Zod-validated, auto-parsed | Manual: `request.json()` |
|
|
276
|
+
| **Output** | Auto-serialized to JSON | Raw `Response` object |
|
|
277
|
+
| **Client** | `client.routes.name(input)` | `client.routes["name"]()` |
|
|
278
|
+
| **Use for** | Business logic, data operations | Webhooks, file uploads, streaming |
|
|
279
|
+
|
|
280
|
+
**Rule of thumb**: Use JSON routes for typed input/output with automatic validation. Use raw routes for HTTP-level control (custom headers, binary data, streams, signature verification).
|
|
281
|
+
|
|
282
|
+
### Webhook Example
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
// routes/webhooks/stripe.ts
|
|
286
|
+
import { route } from "questpie";
|
|
287
|
+
|
|
288
|
+
export default route({
|
|
289
|
+
method: "POST",
|
|
290
|
+
handler: async ({ request, db }) => {
|
|
291
|
+
const body = await request.text();
|
|
292
|
+
const signature = request.headers.get("stripe-signature");
|
|
293
|
+
const event = verifyStripeWebhook(body, signature);
|
|
294
|
+
|
|
295
|
+
await db.insert(webhookEvents).values({
|
|
296
|
+
type: event.type,
|
|
297
|
+
payload: body,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return new Response("OK", { status: 200 });
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Services
|
|
306
|
+
|
|
307
|
+
Services are reusable units of logic injected into `AppContext` under the `services` key. Define in `services/`, and they become available in every hook, route, and job handler.
|
|
308
|
+
|
|
309
|
+
### Defining a Service
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// services/blog.ts
|
|
313
|
+
import { service } from "questpie";
|
|
314
|
+
|
|
315
|
+
const WORDS_PER_MINUTE = 200;
|
|
316
|
+
|
|
317
|
+
function stripHtml(html: string): string {
|
|
318
|
+
return html.replace(/<[^>]*>/g, " ");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export default service({
|
|
322
|
+
lifecycle: "singleton",
|
|
323
|
+
create: () => ({
|
|
324
|
+
computeReadingTime(content: string): number {
|
|
325
|
+
const text = stripHtml(content);
|
|
326
|
+
const words = text
|
|
327
|
+
.trim()
|
|
328
|
+
.split(/\s+/)
|
|
329
|
+
.filter((w) => w.length > 0).length;
|
|
330
|
+
return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
generateSlug(title: string): string {
|
|
334
|
+
return title
|
|
335
|
+
.toLowerCase()
|
|
336
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
337
|
+
.trim()
|
|
338
|
+
.replace(/\s+/g, "-");
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The filename becomes the key: `services/blog.ts` maps to `ctx.services.blog`.
|
|
345
|
+
|
|
346
|
+
### Using Services
|
|
347
|
+
|
|
348
|
+
Services are available via `services` destructuring in any handler:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
.hooks({
|
|
352
|
+
beforeChange: async ({ data, services }) => {
|
|
353
|
+
const { blog } = services;
|
|
354
|
+
if (data.content) {
|
|
355
|
+
data.readingTime = blog.computeReadingTime(data.content);
|
|
356
|
+
}
|
|
357
|
+
if (data.title) {
|
|
358
|
+
data.slug = blog.generateSlug(data.title);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Lifecycle
|
|
365
|
+
|
|
366
|
+
| Lifecycle | Created | Destroyed | Use for |
|
|
367
|
+
| ------------- | ------------------- | -------------- | ---------------------------------------- |
|
|
368
|
+
| `"singleton"` | Once at app startup | App shutdown | External clients, SDKs, connection pools |
|
|
369
|
+
| `"request"` | Per request | End of request | Tenant-scoped DB, user-specific config |
|
|
370
|
+
|
|
371
|
+
### Singleton Service
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// services/stripe.ts
|
|
375
|
+
import { service } from "questpie";
|
|
376
|
+
import Stripe from "stripe";
|
|
377
|
+
|
|
378
|
+
export default service({
|
|
379
|
+
lifecycle: "singleton",
|
|
380
|
+
create: () => new Stripe(process.env.STRIPE_SECRET_KEY!),
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Request-Scoped Service
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
// services/tenant-db.ts
|
|
388
|
+
import { service } from "questpie";
|
|
389
|
+
|
|
390
|
+
export default service({
|
|
391
|
+
lifecycle: "request",
|
|
392
|
+
deps: ["db", "session"] as const,
|
|
393
|
+
create: ({ db, session }) => {
|
|
394
|
+
return createScopedDb(db, session?.user?.tenantId);
|
|
395
|
+
},
|
|
396
|
+
dispose: (scopedDb) => scopedDb.release(),
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Dependencies
|
|
401
|
+
|
|
402
|
+
Services can depend on other services and infrastructure via `deps`. Use `as const` for type safety:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
// services/analytics.ts
|
|
406
|
+
import { service } from "questpie";
|
|
407
|
+
|
|
408
|
+
export default service({
|
|
409
|
+
deps: ["db", "logger"] as const,
|
|
410
|
+
create: ({ db, logger }) => {
|
|
411
|
+
logger.info("Analytics service initialized");
|
|
412
|
+
return new AnalyticsService(db);
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Available dependencies: `db`, `logger`, `kv`, `email`, `queue`, `storage`, `search`, `realtime`, `session`, and any user-defined services.
|
|
418
|
+
|
|
419
|
+
### Disposal
|
|
420
|
+
|
|
421
|
+
Optional `dispose` for cleanup (singleton: at shutdown, request: at end of request):
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
export default service({
|
|
425
|
+
lifecycle: "singleton",
|
|
426
|
+
create: () => createConnectionPool(),
|
|
427
|
+
dispose: async (pool) => {
|
|
428
|
+
await pool.close();
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### API Reference
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
service({
|
|
437
|
+
lifecycle?: "singleton" | "request", // default: "singleton"
|
|
438
|
+
deps?: readonly string[], // dependencies from AppContext
|
|
439
|
+
create: (deps) => TInstance, // factory function
|
|
440
|
+
dispose?: (instance) => void | Promise<void>, // cleanup
|
|
441
|
+
})
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## Emails
|
|
445
|
+
|
|
446
|
+
Email templates are defined in `emails/` and discovered by codegen. Each template has a Zod input schema and a handler that returns `{ subject, html }`.
|
|
447
|
+
|
|
448
|
+
### Defining an Email Template
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
// emails/appointment-confirmation.ts
|
|
452
|
+
import { email } from "questpie";
|
|
453
|
+
import { z } from "zod";
|
|
454
|
+
|
|
455
|
+
export default email({
|
|
456
|
+
name: "appointment-confirmation",
|
|
457
|
+
schema: z.object({
|
|
458
|
+
customerName: z.string(),
|
|
459
|
+
appointmentId: z.string(),
|
|
460
|
+
barberName: z.string(),
|
|
461
|
+
serviceName: z.string(),
|
|
462
|
+
scheduledAt: z.string(),
|
|
463
|
+
}),
|
|
464
|
+
handler: ({ input }) => ({
|
|
465
|
+
subject: "Appointment Confirmed",
|
|
466
|
+
html: `
|
|
467
|
+
<h1>Appointment Confirmed</h1>
|
|
468
|
+
<p>Hi ${input.customerName}, your appointment is confirmed!</p>
|
|
469
|
+
<p><strong>Service:</strong> ${input.serviceName}</p>
|
|
470
|
+
<p><strong>Barber:</strong> ${input.barberName}</p>
|
|
471
|
+
<p><strong>Date:</strong> ${input.scheduledAt}</p>
|
|
472
|
+
`,
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The filename becomes the template key: `appointment-confirmation.ts` maps to `email.sendTemplate({ template: "appointmentConfirmation", ... })`.
|
|
478
|
+
|
|
479
|
+
### Sending Emails
|
|
480
|
+
|
|
481
|
+
Use `email.sendTemplate()` from any handler:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
await email.sendTemplate({
|
|
485
|
+
template: "appointmentConfirmation",
|
|
486
|
+
to: customer.email,
|
|
487
|
+
input: {
|
|
488
|
+
customerName: customer.name,
|
|
489
|
+
appointmentId: appointment.id,
|
|
490
|
+
barberName: appointment.barber.name,
|
|
491
|
+
serviceName: appointment.service.name,
|
|
492
|
+
scheduledAt: appointment.scheduledAt.toISOString(),
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### Dynamic Email Templates
|
|
498
|
+
|
|
499
|
+
Email handlers receive the full `AppContext` for fetching data:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
// emails/weekly-digest.ts
|
|
503
|
+
import { email } from "questpie";
|
|
504
|
+
import { z } from "zod";
|
|
505
|
+
|
|
506
|
+
export default email({
|
|
507
|
+
name: "weekly-digest",
|
|
508
|
+
schema: z.object({ userId: z.string() }),
|
|
509
|
+
handler: async ({ input, collections }) => {
|
|
510
|
+
const user = await collections.users.findOne({
|
|
511
|
+
where: { id: input.userId },
|
|
512
|
+
});
|
|
513
|
+
const recentPosts = await collections.posts.find({
|
|
514
|
+
where: { createdAt: { gte: oneWeekAgo() } },
|
|
515
|
+
limit: 5,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
subject: `Weekly digest for ${user.name}`,
|
|
520
|
+
html: renderDigestHtml(user, recentPosts.docs),
|
|
521
|
+
text: renderDigestText(user, recentPosts.docs),
|
|
522
|
+
};
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Email Result
|
|
528
|
+
|
|
529
|
+
| Property | Type | Required | Description |
|
|
530
|
+
| --------- | -------- | -------- | ------------------- |
|
|
531
|
+
| `subject` | `string` | Yes | Email subject line |
|
|
532
|
+
| `html` | `string` | Yes | HTML body |
|
|
533
|
+
| `text` | `string` | No | Plain text fallback |
|
|
534
|
+
|
|
535
|
+
## Common Mistakes
|
|
536
|
+
|
|
537
|
+
1. **HIGH: Putting business logic in route files.**
|
|
538
|
+
Framework route files should only mount HTTP handlers. Business logic belongs in routes, hooks, or services. Raw routes are for HTTP control (webhooks, file downloads, streaming).
|
|
539
|
+
|
|
540
|
+
2. **HIGH: Not using `export default` on route/job/service/email files.**
|
|
541
|
+
Codegen discovery requires `export default`. Named exports are not discovered.
|
|
542
|
+
|
|
543
|
+
3. **MEDIUM: Accessing `app.collections`/`app.globals` without context.**
|
|
544
|
+
For server-side calls to the collection API, you must create a context first: `const ctx = await app.createContext({ accessMode: "system" })`. Then pass it: `app.collections.posts.find({}, ctx)`.
|
|
545
|
+
|
|
546
|
+
4. **MEDIUM: Defining job handlers without queue configuration.**
|
|
547
|
+
Jobs require a queue adapter in production config. Without it, `queue.jobName.publish()` calls will fail at runtime. Configure via `runtimeConfig({ queue: { adapter: pgBossAdapter(...) } })`.
|
|
548
|
+
|
|
549
|
+
5. **MEDIUM: Confusing route keys with filenames.**
|
|
550
|
+
Filenames use kebab-case (`get-active-barbers.ts`) but the key is camelCase (`getActiveBarbers`). The client SDK uses the camelCase key: `client.routes.getActiveBarbers()`.
|