Add email via Resend

This commit is contained in:
NeilClack
2026-07-06 21:01:41 -05:00
parent 96eafafbf6
commit a42c32f014
16 changed files with 210 additions and 116 deletions
-2
View File
@@ -21,5 +21,3 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/../generated/prisma
+1
View File
@@ -0,0 +1 @@
{}
+53
View File
@@ -0,0 +1,53 @@
# portal.Rhemawavetech.com
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
## Quick start
```sh
npm install # engine-strict=true in .npmrc
npm run prepare # svelte-kit sync (generate types)
npm run dev # vite dev on :5173
```
Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
## Commands
| npm run ... | What it does |
|----------------|-------------|
| `check` | `svelte-kit sync && svelte-check` (typecheck) |
| `lint` | `prettier --check . && eslint .` (order matters) |
| `format` | `prettier --write .` |
| `test` | `vitest --run` (single run) |
| `test:unit` | `vitest` (watch mode) |
| `migrate -- --name <name>` | Create a Prisma migration |
| `deploy` | Apply pending migrations |
| `build` | Production build (adapter-node) |
## Testing quirks
- **Two Vitest projects**: `client` (Playwright/Chromium headless, `src/**/*.svelte.{test,spec}.{js,ts}`) and `server` (node env, everything else). Tests co-located beside source files.
- **`expect: { requireAssertions: true }`** — every test must assert something.
- **All route tests mock `$lib/db`** (Prisma client) and `$lib/auth-client`/`$lib/auth` via `vi.mock()`. Stub factories in `src/lib/test-utils.ts`.
- **No `.env.test`** — mocking `$lib/db` makes real DB unnecessary.
- **Known bug**: register page `throw redirect()` is caught by try/catch and returns 400 instead of redirecting (documented in test).
## Architecture notes
- **Route groups**: `(protected)/` requires auth + profile; `(user)/` is user-facing; `admin/` requires roleId <= 3 (STAFF+).
- **Svelte-Admin**: custom config-driven CRUD in `src/lib/svelte-admin/`. Models registered in `registry.ts`. Dynamic routes `[model]/[id]`.
- **Prisma client** generated to `src/lib/generated/prisma/` (not `node_modules/.prisma/client`). Lazy singleton via Proxy in `src/lib/db.ts`.
- **Better-Auth** server instance in `src/lib/auth.ts`, browser client in `src/lib/auth-client.ts`. Session fetched in `src/hooks.server.ts`.
- **Roles**: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4). `canAccessAdmin` checks roleId <= 3.
- **Payments are stubbed** (`src/lib/payments.ts` — setTimeout + console.log).
## Formatting conventions
- Tabs, single quotes, no trailing commas, 100 print width (enforced by Prettier).
- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`). No legacy stores.
## Generated / build artifacts
- `.svelte-kit/` — SvelteKit generated types (gitignored)
- `src/lib/generated/prisma/` — Prisma client output (not gitignored; regenerate with `npx prisma generate`)
-9
View File
@@ -1,10 +1 @@
# Rhema Wave Technologies - User Portal
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Deployment
- Update the Authorized Redirect URIs on Google Cloud Console
- Update the Better Auth URL
- To migrate development changes to the database, run `npm run migarte -- <migration_name>`
- To run deployment changes to the database, run `npm run deploy`
-12
View File
@@ -1,12 +0,0 @@
Initial Setup:
1. Add the portal flake input to Webserver-Infra/flake.nix with a Forgejo access token.
- Does this need to be in just the inputs, or the outputs as well? I'm still very much learning nix and these things get confusing.
2. Add services.portal.enable = true; to Webserver-Infra/configuration.nix
3. Remove the existing users.users.portal and groups because they exist in this flake now
4. Clone the repo on the server, place the .env, build, then run nixos-rebuild switch. This is mainly to start the service, I presume. The build is just so that the service has something to
start.
To update:
1. git pull
2. Apply shell-instance environment variables for placeholder values to then run `npm ci && npm run build && npm ci --omit=dev`
3. Restart the systemctl service (portal.service) to pickup the changes because the service is in charge of the `node build` command.
-9
View File
@@ -1,9 +0,0 @@
{
description = "RhemaWave Portal";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }: {
nixosModules.default = import ./nixos/module.nix;
};
}
-81
View File
@@ -1,81 +0,0 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.portal;
in
{
options.services.portal = {
enable = lib.mkEnableOption "RhemaWave Portal";
directory = lib.mkOption {
type = lib.types.path;
default = "/mnt/volume_nyc3_01/Portal.RhemaWaveTech.com";
description = "Path to the Portal git clone.";
};
envFile = lib.mkOption {
type = lib.types.path;
default = "${cfg.directory}/.env";
defaultText = lib.literalExpression ''"''${cfg.directory}/.env"'';
description = "Path to the .env file containing secrets.";
};
port = lib.mkOption {
type = lib.types.port;
default = 3001;
description = "Port the Portal listens on.";
};
host = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "Host address the Portal binds to.";
};
};
config = lib.mkIf cfg.enable {
users.users.portal = {
isSystemUser = true;
group = "portal";
home = cfg.directory;
};
users.groups.portal = { };
systemd.services.portal = {
description = "RhemaWave Portal";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "portal";
Group = "portal";
WorkingDirectory = cfg.directory;
EnvironmentFile = cfg.envFile;
ExecStart = "${pkgs.nodejs_22}/bin/node build";
Restart = "on-failure";
RestartSec = 5;
};
environment = {
HOST = cfg.host;
PORT = toString cfg.port;
DO_CA_CERTIFICATE = "/var/lib/portal/do-ca-certificate.crt";
NODE_ENV = "production";
};
};
security.sudo.extraRules = [
{
users = [ "forgejo-runner" ];
commands = [
{
command = "/run/current-system/sw/bin/systemctl restart portal";
options = [ "NOPASSWD" ];
}
];
}
];
};
}
+51 -1
View File
@@ -14,7 +14,8 @@
"@tiptap/starter-kit": "^3.15.3",
"better-auth": "^1.4.10",
"dotenv": "^17.2.3",
"pg": "^8.16.3"
"pg": "^8.16.3",
"resend": "^6.17.1"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -1853,6 +1854,12 @@
"win32"
]
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -4167,6 +4174,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
@@ -5455,6 +5468,12 @@
"node": ">=14.19.0"
}
},
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -6115,6 +6134,27 @@
"node": ">=0.10.0"
}
},
"node_modules/resend": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.17.1.tgz",
"integrity": "sha512-QhHHIRPPM9Tq2uilWmNF8C8kd6nLkUmiFgDQCt1v4eR4o+6p86qrK+Vn12jnAs0jR8Gf6ABdxI18/90LGQ7GVA==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.4",
"standardwebhooks": "1.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6360,6 +6400,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+2 -1
View File
@@ -55,6 +55,7 @@
"@tiptap/starter-kit": "^3.15.3",
"better-auth": "^1.4.10",
"dotenv": "^17.2.3",
"pg": "^8.16.3"
"pg": "^8.16.3",
"resend": "^6.17.1"
}
}
+56
View File
@@ -0,0 +1,56 @@
/** src/lib/server/resend.ts
* AUTHOR: Neil Clack
* EMAIL: nclack@radiantwavetech.com
* DATE: 07/06/2027
*
* A server side library and client for Resend
* Includes the Resend client, as well as functions
* for different email scenarios.
*
*/
import { Resend } from 'resend';
import { RESEND_API_KEY, EMAIL_FROM, RESEND_TEMPLATE_ID } from '$env/static/private';
export const resend = new Resend(RESEND_API_KEY);
/**
* Send a transactional email using a Resend-hosted template.
*
* @example
* await sendMail({
* to: 'jane@example.com',
* templateId: 'tmpl_abc123', // optional — defaults to RESEND_TEMPLATE_ID
* subject: 'Your license key is ready', // optional — defaults to 'Template Email'
* data: { // optional — template variables
* firstName: 'Jane',
* licenseKey: 'RWT-7X9K-2M4Q'
* }
* });
*
* @returns The Resend API response data on success.
* @throws {Error} If the Resend API call fails.
*/
export async function sendMail(options: {
to: string;
subject?: string;
templateId?: string;
data?: Record<string, string | number>;
}) {
const { to, subject, templateId, data } = options;
const result = await resend.emails.send({
from: EMAIL_FROM,
to: [to],
subject: subject ?? 'Template Email',
template: {
id: templateId ?? RESEND_TEMPLATE_ID,
variables: data ?? {}
}
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
}
@@ -0,0 +1,21 @@
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async () => {
// Request validation (400)
// 1. Validate and extract information
// Business rule validation (409)
// 2. Check for existing entries
// Resource creation (500 on unexpected failures)
// 3. Create new entries
// 5. Create license key
// External operations (502/503/504 or 500, depending on your API)
// 4. Send profile setup email
// 6. Send license key email
// Success (200/201)
// 7. Send response
return new Response(JSON.stringify({ "msg": "success" }))
}
+25
View File
@@ -0,0 +1,25 @@
/** src/routes/send/+server.ts
* AUTHOR: Neil Clack
* EMAIL: nclack@radiantwavetech.com
* DATE: 07/06/2027
*
* An example endpoint, temporarily enabled, to describe
* how to send an email with the Resend API and our custom
* sendMail function.
*
*/
import { json } from '@sveltejs/kit';
import { sendMail } from '$lib/server/resend';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const { to, subject, templateId, data } = await request.json();
try {
const result = await sendMail({ to, subject, templateId, data });
return json(result);
} catch (e) {
return json({ error: (e as Error).message }, { status: 400 });
}
};