Add email via Resend
This commit is contained in:
+1
-3
@@ -20,6 +20,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
/../generated/prisma
|
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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`)
|
||||||
@@ -1,10 +1 @@
|
|||||||
# Rhema Wave Technologies - User Portal
|
# 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`
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
description = "RhemaWave Portal";
|
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs }: {
|
|
||||||
nixosModules.default = import ./nixos/module.nix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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" ];
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Generated
+51
-1
@@ -14,7 +14,8 @@
|
|||||||
"@tiptap/starter-kit": "^3.15.3",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.10",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"pg": "^8.16.3"
|
"pg": "^8.16.3",
|
||||||
|
"resend": "^6.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
@@ -1853,6 +1854,12 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -4167,6 +4174,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||||
@@ -5455,6 +5468,12 @@
|
|||||||
"node": ">=14.19.0"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
@@ -6115,6 +6134,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6360,6 +6400,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -55,6 +55,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.15.3",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.10",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"pg": "^8.16.3"
|
"pg": "^8.16.3",
|
||||||
|
"resend": "^6.17.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }))
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user