Verstuur uitnodigingen voor experts via SendGrid, zoals het ZRM-platform
- lib/mail.ts gebruikt SendGrid met dezelfde variabelen als het Rikx ZRM-platform (SENDGRID_API_KEY, SENDGRID_FROM_EMAIL, SENDGRID_FROM_NAME); SMTP blijft beschikbaar als alternatief - Uitnodigingsmail krijgt ook een HTML-versie met knop - Zonder SendGrid of SMTP blijft de pagina Experts de link tonen - .env.example en CLAUDE.md bijgewerkt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -2,7 +2,12 @@ SESSION_SECRET=vervang-dit-met-een-willekeurige-string-van-64-tekens
|
|||||||
DB_PATH=./data/app.db
|
DB_PATH=./data/app.db
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Email (voor workflow-notificaties)
|
# E-mail (uitnodigingen voor experts). Voorkeur: SendGrid, dezelfde instellingen als het Rikx ZRM-platform
|
||||||
|
SENDGRID_API_KEY=
|
||||||
|
SENDGRID_FROM_EMAIL=no-reply@rikxplatform.nl
|
||||||
|
SENDGRID_FROM_NAME=Rikx
|
||||||
|
|
||||||
|
# Alternatief: SMTP (alleen gebruikt als SENDGRID_API_KEY leeg is)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i
|
|||||||
- Impactmaker van een project = sociaal ondernemer: `projects.impactmaker_account_id` verwijst naar `accounts.id` in de platformdatabase (geen foreign key; namen via `listAccountNames()` in `lib/platform-db.ts`)
|
- Impactmaker van een project = sociaal ondernemer: `projects.impactmaker_account_id` verwijst naar `accounts.id` in de platformdatabase (geen foreign key; namen via `listAccountNames()` in `lib/platform-db.ts`)
|
||||||
- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs, gekoppelde experts; dezelfde koppeling `expert_rondes` als op `/experts`); een project hoort optioneel bij één ronde (`projects.ronde_id`)
|
- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs, gekoppelde experts; dezelfde koppeling `expert_rondes` als op `/experts`); een project hoort optioneel bij één ronde (`projects.ronde_id`)
|
||||||
- `/projecten/[id]` — projectpagina met tabs: Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`) en Rikx calculatie (alleen zichtbaar vanaf status Waardebepaling, zie `RIKX_CALCULATIE_VANAF_STATUS` in `lib/projectStatus.ts`; rekenmethode `arbeid`, `jeugd` is zichtbaar maar nog niet beschikbaar). Onderaan de tab: Beoordeling (`beoordeling`: goedgekeurd/afgekeurd/leeg) met `beoordeling_reden`. Kolom `rikx_calculatie_aan` is van de oude schuif en wordt niet meer gebruikt. Uitkomsten worden live berekend met `lib/rikxCalculatie.ts`
|
- `/projecten/[id]` — projectpagina met tabs: Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`) en Rikx calculatie (alleen zichtbaar vanaf status Waardebepaling, zie `RIKX_CALCULATIE_VANAF_STATUS` in `lib/projectStatus.ts`; rekenmethode `arbeid`, `jeugd` is zichtbaar maar nog niet beschikbaar). Onderaan de tab: Beoordeling (`beoordeling`: goedgekeurd/afgekeurd/leeg) met `beoordeling_reden`. Kolom `rikx_calculatie_aan` is van de oude schuif en wordt niet meer gebruikt. Uitkomsten worden live berekend met `lib/rikxCalculatie.ts`
|
||||||
- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SMTP; zonder SMTP_HOST toont de pagina de link om zelf door te geven
|
- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SendGrid of SMTP; zonder beide toont de pagina de link om zelf door te geven
|
||||||
- `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database)
|
- `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database)
|
||||||
- `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet
|
- `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet
|
||||||
- Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie
|
- Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie
|
||||||
@@ -21,7 +21,7 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i
|
|||||||
- Next.js 16 + TypeScript + Tailwind CSS
|
- Next.js 16 + TypeScript + Tailwind CSS
|
||||||
- SQLite via `node:sqlite` (ingebouwd in Node 22, geen extra package)
|
- SQLite via `node:sqlite` (ingebouwd in Node 22, geen extra package)
|
||||||
- HMAC-signed sessie-cookie auth (geen externe auth-provider)
|
- HMAC-signed sessie-cookie auth (geen externe auth-provider)
|
||||||
- Nodemailer voor email-notificaties
|
- E-mail via SendGrid (zoals het ZRM-platform) of Nodemailer/SMTP — `lib/mail.ts`
|
||||||
|
|
||||||
## Lokaal draaien
|
## Lokaal draaien
|
||||||
```bash
|
```bash
|
||||||
@@ -61,7 +61,10 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
|
|||||||
| `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) |
|
| `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) |
|
||||||
| `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) |
|
| `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) |
|
||||||
| `NODE_ENV` | `development` | In productie: `production` |
|
| `NODE_ENV` | `development` | In productie: `production` |
|
||||||
| `SMTP_HOST` | — | SMTP server voor e-mail (o.a. uitnodigingen van experts); leeg = geen e-mail, link wordt in beeld getoond |
|
| `SENDGRID_API_KEY` | — | E-mail via SendGrid (uitnodigingen voor experts); zelfde key als het Rikx ZRM-platform. Nooit committen. Leeg én geen SMTP = geen e-mail, link wordt in beeld getoond |
|
||||||
|
| `SENDGRID_FROM_EMAIL` | `no-reply@rikxplatform.nl` | Afzender bij SendGrid (domein is daar al geverifieerd voor ZRM) |
|
||||||
|
| `SENDGRID_FROM_NAME` | `Rikx` | Afzendernaam |
|
||||||
|
| `SMTP_HOST` | — | Alternatief voor SendGrid: SMTP server (alleen gebruikt als `SENDGRID_API_KEY` leeg is) |
|
||||||
| `SMTP_PORT` | `587` | SMTP poort |
|
| `SMTP_PORT` | `587` | SMTP poort |
|
||||||
| `SMTP_USER` | — | SMTP gebruikersnaam |
|
| `SMTP_USER` | — | SMTP gebruikersnaam |
|
||||||
| `SMTP_PASS` | — | SMTP wachtwoord |
|
| `SMTP_PASS` | — | SMTP wachtwoord |
|
||||||
|
|||||||
+11
-1
@@ -14,6 +14,10 @@ export type InviteState =
|
|||||||
| { status: "sent"; message: string }
|
| { status: "sent"; message: string }
|
||||||
| { status: "link"; message: string; link: string };
|
| { status: "link"; message: string; link: string };
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||||
|
}
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
function readRondeIds(formData: FormData): number[] {
|
function readRondeIds(formData: FormData): number[] {
|
||||||
@@ -39,15 +43,21 @@ async function invite(userId: number, name: string, email: string): Promise<Invi
|
|||||||
if (!isMailConfigured()) {
|
if (!isMailConfigured()) {
|
||||||
return {
|
return {
|
||||||
status: "link",
|
status: "link",
|
||||||
message: `E-mail is nog niet ingesteld (SMTP). Geef deze link zelf door aan ${name}; hij is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.`,
|
message: `E-mail is nog niet ingesteld (SendGrid of SMTP). Geef deze link zelf door aan ${name}; hij is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.`,
|
||||||
link,
|
link,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
|
toName: name,
|
||||||
subject: "Uitnodiging Rikx expertteam",
|
subject: "Uitnodiging Rikx expertteam",
|
||||||
text: `Beste ${name},\n\nJe bent uitgenodigd voor het Rikx expertteam. Via onderstaande link stel je je wachtwoord in en kun je de projecten in waardebepaling beoordelen.\n\n${link}\n\nDe link is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.\n\nMet vriendelijke groet,\nRikx`,
|
text: `Beste ${name},\n\nJe bent uitgenodigd voor het Rikx expertteam. Via onderstaande link stel je je wachtwoord in en kun je de projecten in waardebepaling beoordelen.\n\n${link}\n\nDe link is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.\n\nMet vriendelijke groet,\nRikx`,
|
||||||
|
html: `<p>Beste ${escapeHtml(name)},</p>
|
||||||
|
<p>Je bent uitgenodigd voor het Rikx expertteam. Via onderstaande knop stel je je wachtwoord in en kun je de projecten in waardebepaling beoordelen.</p>
|
||||||
|
<p><a href="${link}" style="display:inline-block;background:#3d0a0a;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none">Wachtwoord instellen</a></p>
|
||||||
|
<p style="color:#666;font-size:13px">De link is ${UITNODIGING_GELDIG_DAGEN} dagen geldig. Werkt de knop niet? Kopieer dan deze link: ${link}</p>
|
||||||
|
<p>Met vriendelijke groet,<br>Rikx</p>`,
|
||||||
});
|
});
|
||||||
return { status: "sent", message: `Uitnodiging verstuurd naar ${email}.` };
|
return { status: "sent", message: `Uitnodiging verstuurd naar ${email}.` };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+33
-5
@@ -1,12 +1,39 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
// E-mail via SMTP (zie SMTP_* in CLAUDE.md). Zonder SMTP_HOST wordt er niets verstuurd;
|
// E-mail versturen. Voorkeur: SendGrid, met dezelfde instellingen als het Rikx ZRM-platform
|
||||||
// de aanroeper toont dan bijvoorbeeld de uitnodigingslink in beeld.
|
// (SENDGRID_API_KEY, SENDGRID_FROM_EMAIL, SENDGRID_FROM_NAME). Anders SMTP (SMTP_*).
|
||||||
|
// Zonder beide wordt er niets verstuurd; de aanroeper toont dan bijvoorbeeld de uitnodigingslink in beeld.
|
||||||
export function isMailConfigured(): boolean {
|
export function isMailConfigured(): boolean {
|
||||||
return Boolean(process.env.SMTP_HOST);
|
return Boolean(process.env.SENDGRID_API_KEY || process.env.SMTP_HOST);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMail(message: { to: string; subject: string; text: string; html?: string }): Promise<void> {
|
type Message = { to: string; toName?: string; subject: string; text: string; html?: string };
|
||||||
|
|
||||||
|
export async function sendMail(message: Message): Promise<void> {
|
||||||
|
if (process.env.SENDGRID_API_KEY) return sendViaSendGrid(message, process.env.SENDGRID_API_KEY);
|
||||||
|
return sendViaSmtp(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendViaSendGrid(message: Message, apiKey: string): Promise<void> {
|
||||||
|
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
personalizations: [{ to: [{ email: message.to, name: message.toName }], subject: message.subject }],
|
||||||
|
from: {
|
||||||
|
email: process.env.SENDGRID_FROM_EMAIL || "no-reply@rikxplatform.nl",
|
||||||
|
name: process.env.SENDGRID_FROM_NAME || "Rikx",
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ type: "text/plain", value: message.text },
|
||||||
|
...(message.html ? [{ type: "text/html", value: message.html }] : []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`SendGrid ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendViaSmtp(message: Message): Promise<void> {
|
||||||
const port = Number(process.env.SMTP_PORT || 587);
|
const port = Number(process.env.SMTP_PORT || 587);
|
||||||
const transport = nodemailer.createTransport({
|
const transport = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
@@ -14,5 +41,6 @@ export async function sendMail(message: { to: string; subject: string; text: str
|
|||||||
secure: port === 465,
|
secure: port === 465,
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
|
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
|
||||||
});
|
});
|
||||||
await transport.sendMail({ from: process.env.SMTP_FROM || "noreply@rikx.nl", ...message });
|
const { toName: _toName, ...rest } = message;
|
||||||
|
await transport.sendMail({ from: process.env.SMTP_FROM || "noreply@rikx.nl", ...rest });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user