diff --git a/.gitignore b/.gitignore index b2240a9..a122eb5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,4 @@ Thumbs.db # Vite vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -/../generated/prisma +vite.config.ts.timestamp-* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d215b3d --- /dev/null +++ b/AGENTS.md @@ -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 ` | 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`) diff --git a/README.md b/README.md index 70f0447..9435861 100644 --- a/README.md +++ b/README.md @@ -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 -- ` -- To run deployment changes to the database, run `npm run deploy` diff --git a/deploy.md b/deploy.md deleted file mode 100644 index 0c50fcd..0000000 --- a/deploy.md +++ /dev/null @@ -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. diff --git a/flake.nix b/flake.nix deleted file mode 100644 index e5d870a..0000000 --- a/flake.nix +++ /dev/null @@ -1,9 +0,0 @@ -{ - description = "RhemaWave Portal"; - - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - - outputs = { self, nixpkgs }: { - nixosModules.default = import ./nixos/module.nix; - }; -} diff --git a/nixos/module.nix b/nixos/module.nix deleted file mode 100644 index 5978065..0000000 --- a/nixos/module.nix +++ /dev/null @@ -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" ]; - } - ]; - } - ]; - }; -} diff --git a/package-lock.json b/package-lock.json index f394660..a9f3407 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9469967..0878ce5 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/lib/server/resend.ts b/src/lib/server/resend.ts new file mode 100644 index 0000000..49b6f50 --- /dev/null +++ b/src/lib/server/resend.ts @@ -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; +}) { + 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; +} \ No newline at end of file diff --git a/src/routes/account/setup/+page.server.ts b/src/routes/(protected)/(user)/account/setup/+page.server.ts similarity index 100% rename from src/routes/account/setup/+page.server.ts rename to src/routes/(protected)/(user)/account/setup/+page.server.ts diff --git a/src/routes/account/setup/+page.svelte b/src/routes/(protected)/(user)/account/setup/+page.svelte similarity index 100% rename from src/routes/account/setup/+page.svelte rename to src/routes/(protected)/(user)/account/setup/+page.svelte diff --git a/src/routes/api/v1/automations/create/account/+server.ts b/src/routes/api/v1/automations/create/account/+server.ts new file mode 100644 index 0000000..2c0cec4 --- /dev/null +++ b/src/routes/api/v1/automations/create/account/+server.ts @@ -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" })) +} \ No newline at end of file diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/send/+server.ts b/src/routes/send/+server.ts new file mode 100644 index 0000000..4685b8d --- /dev/null +++ b/src/routes/send/+server.ts @@ -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 }); + } +}; \ No newline at end of file