diff --git a/bun.lockb b/bun.lockb index 5248061c..e9dc09a5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/infra/bus.ts b/infra/bus.ts index a81ae7d8..338edabb 100644 --- a/infra/bus.ts +++ b/infra/bus.ts @@ -1,9 +1,16 @@ import { database } from "./database"; +import { email } from "./email"; import { allSecrets } from "./secret"; export const bus = new sst.aws.Bus("Bus"); bus.subscribe({ handler: "./packages/functions/src/event/event.handler", - link: [database, ...allSecrets], + link: [database, email, ...allSecrets], + permissions: [ + { + actions: ["ses:SendEmail"], + resources: ["*"], + }, + ], }); diff --git a/infra/short.ts b/infra/short.ts new file mode 100644 index 00000000..b598ef91 --- /dev/null +++ b/infra/short.ts @@ -0,0 +1,16 @@ +if ($app.stage === "production") { + const zone = cloudflare.getZoneOutput({ + name: "trm.sh", + }); + new cloudflare.PageRule("ShortRedirect", { + zoneId: zone.id, + target: "trm.sh/*", + actions: { + forwardingUrl: { + url: "https://terminal.shop/$1", + statusCode: 301, + }, + }, + priority: 1, + }); +} diff --git a/packages/core/package.json b/packages/core/package.json index 1ad42b7a..a2f149bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,8 @@ "@types/luxon": "3.4.2" }, "dependencies": { + "@aws-sdk/client-sesv2": "3.606.0", + "@terminal/email": "workspace:", "@hono/valibot-validator": "0.2.2", "@hono/zod-openapi": "0.11.0", "@paralleldrive/cuid2": "2.2.2", diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts new file mode 100644 index 00000000..c1980795 --- /dev/null +++ b/packages/core/src/email/index.ts @@ -0,0 +1,37 @@ +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { Resource } from "sst"; + +export module Email { + const ses = new SESv2Client({}); + + export async function send( + from: string, + to: string, + subject: string, + body: string, + ) { + from = from + "@" + Resource.Email.sender; + console.log("sending email", subject, from, to); + const params = { + Destination: { + ToAddresses: [to], + }, + Content: { + Simple: { + Body: { + Html: { + Data: body, + }, + }, + Subject: { + Data: subject, + }, + }, + }, + FromEmailAddress: from, + }; + + const command = new SendEmailCommand(params); + await ses.send(command); + } +} diff --git a/packages/core/src/email/template.ts b/packages/core/src/email/template.ts new file mode 100644 index 00000000..8bbd1904 --- /dev/null +++ b/packages/core/src/email/template.ts @@ -0,0 +1,53 @@ +import { Sample } from "@terminal/email/templates/email"; +import { useTransaction } from "@terminal/core/drizzle/transaction"; +import { render as renderTemplate } from "jsx-email"; +import { userTable } from "../user/user.sql"; +import { orderTable } from "../order/order.sql"; +import { and, count, eq, lte, sql } from "drizzle-orm"; +import { Email } from "./index"; +export module Template { + const templates = { + Sample, + }; + + export function render( + name: Name, + data: Parameters<(typeof templates)[Name]>[0], + ) { + return renderTemplate(templates[name](data)); + } + + export async function sendOrderConfirmation(orderID: string) { + const order = await useTransaction((tx) => + tx + .select({ + email: userTable.email, + name: userTable.name, + index: sql`${tx + .select({ index: count() }) + .from(orderTable) + .where( + and( + eq(orderTable.userID, userTable.id), + lte(orderTable.id, orderID), + ), + )}`, + }) + .from(orderTable) + .innerJoin(userTable, eq(userTable.id, orderTable.userID)) + .where(eq(orderTable.id, orderID)) + .then((rows) => rows[0]), + ); + if (!order) throw new Error(`Order not found: ${orderID}`); + const template = await render("Sample", { + email: order.email!, + name: order.name!, + }); + await Email.send( + "order", + order.email!, + `Terminal Order #${order.index} Confirmation`, + template, + ); + } +} diff --git a/packages/core/src/shippo/index.ts b/packages/core/src/shippo/index.ts index 08d4da5a..925c356e 100644 --- a/packages/core/src/shippo/index.ts +++ b/packages/core/src/shippo/index.ts @@ -17,6 +17,7 @@ export module Shippo { name: sql`CONCAT(${productTable.name}, " - ", ${productVariantTable.name})`, quantity: orderItem.quantity, amount: orderItem.amount, + trackingNumber: orderTable.trackingNumber, }) .from(orderTable) .innerJoin(orderItem, eq(orderItem.orderID, orderTable.id)) @@ -32,6 +33,10 @@ export module Shippo { .execute(), ); if (!items.length) throw new Error("Order not found"); + if (items.some((item) => item.trackingNumber)) { + console.log("shipment already created", orderID); + return; + } const shipping = items[0]!.address; const weight = items.reduce((sum, item) => sum + item.quantity * 12, 0); const order = await api("POST", "/orders", { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index b570b875..9765a1ac 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "module": "esnext", + "jsx": "react", "moduleResolution": "bundler", "noUncheckedIndexedAccess": true, } diff --git a/packages/email/.gitignore b/packages/email/.gitignore new file mode 100644 index 00000000..97797f47 --- /dev/null +++ b/packages/email/.gitignore @@ -0,0 +1,12 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +node_modules + +# env +.env + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* \ No newline at end of file diff --git a/packages/email/README.md b/packages/email/README.md new file mode 100644 index 00000000..4f722137 --- /dev/null +++ b/packages/email/README.md @@ -0,0 +1 @@ +# email diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 00000000..1c16269a --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,19 @@ +{ + "name": "@terminal/email", + "version": "0.0.0", + "private": true, + "description": "A simple starter for jsx-email", + "scripts": { + "build": "email build ./templates", + "create": "email create", + "dev": "email preview ./templates" + }, + "dependencies": { + "jsx-email": "^1.10.11" + }, + "devDependencies": { + "react": "^18.2.0", + "@types/react": "^18.2.0", + "typescript": "^5.2.2" + } +} diff --git a/packages/email/templates/email.tsx b/packages/email/templates/email.tsx new file mode 100644 index 00000000..8a3b899d --- /dev/null +++ b/packages/email/templates/email.tsx @@ -0,0 +1,85 @@ +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Text, +} from "jsx-email"; + +export type TemplateProps = { + email: string; + name: string; +}; + +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + marginBottom: "64px", + padding: "20px 0 48px", +}; + +const box = { + padding: "0 48px", +}; + +const hr = { + borderColor: "#e6ebf1", + margin: "20px 0", +}; + +const paragraph = { + color: "#777", + fontSize: "16px", + lineHeight: "24px", + textAlign: "left" as const, +}; + +const anchor = { + color: "#777", +}; + +const button = { + backgroundColor: "#777", + borderRadius: "5px", + color: "#fff", + display: "block", + fontSize: "16px", + fontWeight: "bold", + textAlign: "center" as const, + textDecoration: "none", + width: "100%", + padding: "10px", +}; + +export const defaultProps = {} as TemplateProps; + +export const templateName = "email"; + +export const Sample = (_props: TemplateProps) => ( + + + Order Confirmed + + +
+ Order Confirmed + +
+ + Look we're still working on this email template okay? + +
+
+ + +); diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json new file mode 100644 index 00000000..888d0bfc --- /dev/null +++ b/packages/email/tsconfig.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "node", + "noEmitOnError": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "preserveSymlinks": true, + "preserveWatchOutput": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "target": "ESNext" + }, + "exclude": ["**/dist", "**/node_modules"], + "include": ["templates"] +} diff --git a/packages/functions/src/event/event.ts b/packages/functions/src/event/event.ts index b8550f61..213d8ced 100644 --- a/packages/functions/src/event/event.ts +++ b/packages/functions/src/event/event.ts @@ -3,6 +3,12 @@ import { Order } from "@terminal/core/order/order"; import { Shippo } from "@terminal/core/shippo/index"; import { User } from "@terminal/core/user/index"; import { Stripe } from "@terminal/core/stripe"; +import { Template } from "@terminal/core/email/template"; +import { Email } from "@terminal/core/email/index"; +import { useTransaction } from "@terminal/core/drizzle/transaction"; +import { userTable } from "@terminal/core/user/user.sql"; +import { orderTable } from "@terminal/core/order/order.sql"; +import { eq } from "@terminal/core/drizzle/index"; export const handler = bus.subscriber( [Order.Event.Created, User.Events.Updated], @@ -11,6 +17,7 @@ export const handler = bus.subscriber( switch (event.type) { case "order.created": { await Shippo.createShipment(event.properties.orderID); + await Template.sendOrderConfirmation(event.properties.orderID); break; } case "user.updated": {