mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(forms): M10c auto-scheduler + mana-mail bulk-send
Headless wave-send während die Mana-Tab offen ist (M10c). Echter
Server-Cron (mana-ai oder mana-notify) bleibt M10d.
- lib/wave-mail.ts: sendWaveViaBulkMail POSTet an
/api/v1/mail/bulk-send mit form-derived payload (subject =
"{title} — {cohort}", htmlBody mit Inline-CSS + share-link-Button +
Impressum + Abmelden-Footer, textBody plain). campaignId =
form-{formId}-{cohort} (idempotent über Retries). Wirft
WavePreconditionError wenn fromEmail/fromName/legalAddress fehlen
oder Empfänger leer sind — Caller fällt auf mailto-Bridge zurück.
- lib/wave-scheduler.ts: singleton setInterval (5 min,
Page-visibility-aware — pausiert bei hidden), Tick scant formTable,
dekrypt-aware, filtert published+token+recurrence+recipients+due,
ruft sendWaveViaBulkMail + markWaveSent. Wirft nicht — per-form
errors werden als console.warn geloggt, Schedule läuft weiter.
Initial-tick 30s nach start damit Montag-Morgen-Welle nicht
5 Minuten warten muss. start/stop idempotent.
- BuilderView.sendWave: versucht erst bulk-send (wenn
broadcasts-Settings configured = defaultFromEmail + legalAddress),
fällt auf mailto-Bridge zurück (M10b) wenn precondition fehlt.
waveError-state für non-precondition-Fehler. Confirm-Dialog hat
jetzt zwei Texte (confirmBulk vs confirmSend).
- (app)/+layout.svelte: startWaveScheduler() neben startMissionTick()
beim Auth-Ready, stopWaveScheduler() im onDestroy.
- 5 neue i18n-Keys × 5 Locales (forms.builder.recurrence.confirmBulk).
Parity 6495.
Trade-offs:
- Auto-Tick nur während Tab offen — headless Cron via mana-ai-Mission
oder mana-notify-Worker bleibt M10d.
- Bulk-send bypasst die Campaign-Pipeline der broadcasts (kein
Audience-Filter, kein Rich-Editor) — ist Absicht für Forms-Wellen
als kurze transactional notifications.
- DSGVO: Impressum + Abmelden-Footer ({{unsubscribe_url}} wird vom
Orchestrator pro Empfänger ersetzt) sind Pflicht via
WavePreconditionError; Mailto-Fallback hat das nicht — User-Risk.
Forms-Tests 61/61 unverändert. svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
664b8241d0
commit
7d8e562091
10 changed files with 430 additions and 5 deletions
|
|
@ -91,7 +91,8 @@
|
|||
"needsRecipients": "Trage Empfänger-Emails in den Settings ein.",
|
||||
"mailSubject": "Bitte ausfüllen: {title}",
|
||||
"mailBody": "{description}Hier kannst du antworten:\n{url}\n\nDanke!",
|
||||
"confirmSend": "Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt."
|
||||
"confirmSend": "Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt.",
|
||||
"confirmBulk": "Welle an {n} Empfänger via Mana-Mail senden? Antworten landen direkt in deinem Forms-Inbox."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-Sync — bei Antwort erzeugen",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@
|
|||
"needsRecipients": "Add recipient emails in the settings.",
|
||||
"mailSubject": "Please fill in: {title}",
|
||||
"mailBody": "{description}Reply here:\n{url}\n\nThanks!",
|
||||
"confirmSend": "Send wave to {n} recipients? Your mail client opens with the BCC list and link prefilled. We'll stamp the timestamp after."
|
||||
"confirmSend": "Send wave to {n} recipients? Your mail client opens with the BCC list and link prefilled. We'll stamp the timestamp after.",
|
||||
"confirmBulk": "Send wave to {n} recipients via Mana Mail? Replies land directly in your forms inbox."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — create on submit",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@
|
|||
"needsRecipients": "Añade emails de destinatarios en los ajustes.",
|
||||
"mailSubject": "Por favor rellena: {title}",
|
||||
"mailBody": "{description}Responde aquí:\n{url}\n\n¡Gracias!",
|
||||
"confirmSend": "¿Enviar ola a {n} destinatarios? Tu cliente de correo se abrirá con la lista BCC y el enlace prellenado. Marcamos la marca de tiempo después."
|
||||
"confirmSend": "¿Enviar ola a {n} destinatarios? Tu cliente de correo se abrirá con la lista BCC y el enlace prellenado. Marcamos la marca de tiempo después.",
|
||||
"confirmBulk": "¿Enviar ola a {n} destinatarios vía Mana-Mail? Las respuestas llegan directamente a tu bandeja de Forms."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — crear al recibir respuesta",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@
|
|||
"needsRecipients": "Ajoute des emails de destinataires dans les paramètres.",
|
||||
"mailSubject": "Merci de remplir : {title}",
|
||||
"mailBody": "{description}Réponds ici :\n{url}\n\nMerci !",
|
||||
"confirmSend": "Envoyer la vague à {n} destinataires ? Ton client mail s'ouvre avec la liste BCC et le lien préremplis. On marque l'horodatage ensuite."
|
||||
"confirmSend": "Envoyer la vague à {n} destinataires ? Ton client mail s'ouvre avec la liste BCC et le lien préremplis. On marque l'horodatage ensuite.",
|
||||
"confirmBulk": "Envoyer la vague à {n} destinataires via Mana-Mail ? Les réponses arrivent directement dans ta boîte Forms."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — créer à la soumission",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@
|
|||
"needsRecipients": "Aggiungi email dei destinatari nelle impostazioni.",
|
||||
"mailSubject": "Per favore compila: {title}",
|
||||
"mailBody": "{description}Rispondi qui:\n{url}\n\nGrazie!",
|
||||
"confirmSend": "Invia l'ondata a {n} destinatari? Il tuo client di posta si apre con la lista BCC e il link precompilati. Marchiamo l'orario dopo."
|
||||
"confirmSend": "Invia l'ondata a {n} destinatari? Il tuo client di posta si apre con la lista BCC e il link precompilati. Marchiamo l'orario dopo.",
|
||||
"confirmBulk": "Invia l'ondata a {n} destinatari via Mana-Mail? Le risposte arrivano direttamente nella tua casella Forms."
|
||||
},
|
||||
"autoSync": {
|
||||
"title": "Auto-sync — crea al ricevere risposta",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export {
|
|||
export { computeCohort, cohortLabel, sortCohortsDesc } from './lib/cohort';
|
||||
export type { RecurrenceFrequency } from './lib/cohort';
|
||||
export { nextWaveDueAt, isWaveDue, buildWaveMailto, parseRecipientEmails } from './lib/wave';
|
||||
export { sendWaveViaBulkMail, WavePreconditionError } from './lib/wave-mail';
|
||||
export type { WaveBulkSendResult } from './lib/wave-mail';
|
||||
export { startWaveScheduler, stopWaveScheduler } from './lib/wave-scheduler';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
export {
|
||||
|
|
|
|||
181
apps/mana/apps/web/src/lib/modules/forms/lib/wave-mail.ts
Normal file
181
apps/mana/apps/web/src/lib/modules/forms/lib/wave-mail.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Wave bulk-send via mana-mail (M10c).
|
||||
*
|
||||
* Headless wave-send for recurring forms: instead of opening a
|
||||
* mailto: link (the M10b bridge, which requires a user gesture and
|
||||
* caps at ~50 BCC recipients), we POST directly to mana-mail's
|
||||
* /v1/mail/bulk-send with a minimal HTML/text payload built from
|
||||
* the form's title + description + share-link.
|
||||
*
|
||||
* Pre-conditions:
|
||||
* - The form has `recurrence` configured + non-empty
|
||||
* `recipientEmails` + a published unlisted share-link.
|
||||
* - The user has filled BroadcastSettings.defaultFromEmail and
|
||||
* legalAddress in /broadcasts/settings (DSGVO mandate).
|
||||
*
|
||||
* If those preconditions aren't met, the caller falls back to the
|
||||
* M10b mailto bridge.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import type { BroadcastSettings } from '$lib/modules/broadcasts/types';
|
||||
import type { Form } from '../types';
|
||||
|
||||
export interface WaveBulkSendResult {
|
||||
campaignId: string;
|
||||
accepted: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
errors: Array<{ email: string; reason: string }>;
|
||||
}
|
||||
|
||||
export class WavePreconditionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WavePreconditionError';
|
||||
}
|
||||
}
|
||||
|
||||
function getMailUrl(): string {
|
||||
if (browser) {
|
||||
const fromWindow = (window as unknown as { __PUBLIC_MANA_MAIL_URL__?: string })
|
||||
.__PUBLIC_MANA_MAIL_URL__;
|
||||
if (fromWindow) return fromWindow;
|
||||
}
|
||||
return import.meta.env.PUBLIC_MANA_MAIL_URL || 'http://localhost:3042';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a minimal HTML body for the wave email. Inline-styled because
|
||||
* email clients ignore <style> blocks. Mirrors the structure
|
||||
* broadcasts/render uses but without the rich-text editor + analytics
|
||||
* pixels — wave-mails are short transactional notifications.
|
||||
*/
|
||||
function renderWaveHtml(opts: {
|
||||
formTitle: string;
|
||||
formDescription: string | null;
|
||||
shareUrl: string;
|
||||
settings: BroadcastSettings;
|
||||
}): string {
|
||||
const desc = opts.formDescription
|
||||
? `<p style="margin:0 0 1em;color:#374151;line-height:1.5;">${escapeHtml(opts.formDescription)}</p>`
|
||||
: '';
|
||||
return [
|
||||
'<!doctype html><html><body style="font-family:system-ui,-apple-system,sans-serif;max-width:560px;margin:2em auto;padding:0 1em;">',
|
||||
`<h1 style="margin:0 0 0.5em;font-size:1.25rem;">${escapeHtml(opts.formTitle)}</h1>`,
|
||||
desc,
|
||||
`<p style="margin:1.5em 0;"><a href="${escapeHtml(opts.shareUrl)}" style="display:inline-block;padding:0.625rem 1.25rem;background:#14b8a6;color:white;border-radius:6px;text-decoration:none;font-weight:500;">Antworten</a></p>`,
|
||||
`<p style="margin:1em 0;color:#6b7280;font-size:0.875rem;">Oder direkt: <a href="${escapeHtml(opts.shareUrl)}">${escapeHtml(opts.shareUrl)}</a></p>`,
|
||||
`<hr style="border:none;border-top:1px solid #e5e7eb;margin:2em 0 1em;">`,
|
||||
`<p style="margin:0;color:#9ca3af;font-size:0.75rem;line-height:1.5;white-space:pre-wrap;">${escapeHtml(opts.settings.legalAddress)}</p>`,
|
||||
`<p style="margin:0.5em 0 0;color:#9ca3af;font-size:0.75rem;"><a href="{{unsubscribe_url}}" style="color:#9ca3af;">Abmelden</a></p>`,
|
||||
'</body></html>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderWaveText(opts: {
|
||||
formTitle: string;
|
||||
formDescription: string | null;
|
||||
shareUrl: string;
|
||||
settings: BroadcastSettings;
|
||||
}): string {
|
||||
const desc = opts.formDescription ? `${opts.formDescription}\n\n` : '';
|
||||
return [
|
||||
opts.formTitle,
|
||||
'',
|
||||
desc + `Antworten: ${opts.shareUrl}`,
|
||||
'',
|
||||
'---',
|
||||
opts.settings.legalAddress,
|
||||
'',
|
||||
'Abmelden: {{unsubscribe_url}}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a wave via mana-mail bulk-send. Throws WavePreconditionError if
|
||||
* the broadcast-settings/recipients/share-link state isn't valid —
|
||||
* caller decides whether to surface the error or fall back to mailto.
|
||||
*/
|
||||
export async function sendWaveViaBulkMail(opts: {
|
||||
form: Form;
|
||||
shareUrl: string;
|
||||
settings: BroadcastSettings | null;
|
||||
cohort: string;
|
||||
}): Promise<WaveBulkSendResult> {
|
||||
const { form, shareUrl, settings, cohort } = opts;
|
||||
const recipients = form.settings.recurrence?.recipientEmails ?? [];
|
||||
|
||||
if (!shareUrl) {
|
||||
throw new WavePreconditionError('Share-Link fehlt — Form ist nicht unlisted.');
|
||||
}
|
||||
if (recipients.length === 0) {
|
||||
throw new WavePreconditionError('Keine Empfänger konfiguriert.');
|
||||
}
|
||||
if (!settings) {
|
||||
throw new WavePreconditionError(
|
||||
'Broadcasts-Settings fehlen — konfiguriere Absender + Impressum unter /broadcasts/settings.'
|
||||
);
|
||||
}
|
||||
const fromEmail = settings.defaultFromEmail?.trim();
|
||||
const fromName = settings.defaultFromName?.trim();
|
||||
if (!fromEmail) {
|
||||
throw new WavePreconditionError('Absender-Email fehlt in den Broadcasts-Settings.');
|
||||
}
|
||||
if (!fromName) {
|
||||
throw new WavePreconditionError('Absender-Name fehlt in den Broadcasts-Settings.');
|
||||
}
|
||||
if (!settings.legalAddress?.trim()) {
|
||||
throw new WavePreconditionError('Impressum fehlt in den Broadcasts-Settings (DSGVO-Pflicht).');
|
||||
}
|
||||
|
||||
const subject = `${form.title} — ${cohort}`;
|
||||
const htmlBody = renderWaveHtml({
|
||||
formTitle: form.title,
|
||||
formDescription: form.description,
|
||||
shareUrl,
|
||||
settings,
|
||||
});
|
||||
const textBody = renderWaveText({
|
||||
formTitle: form.title,
|
||||
formDescription: form.description,
|
||||
shareUrl,
|
||||
settings,
|
||||
});
|
||||
|
||||
// campaignId — synthetic, derived from the form + cohort so the
|
||||
// orchestrator's idempotency keys play nicely. Multiple sends of
|
||||
// the same wave (manual retry) produce the same id.
|
||||
const campaignId = `form-${form.id}-${cohort}`.slice(0, 80);
|
||||
|
||||
const res = await fetch(`${getMailUrl()}/api/v1/mail/bulk-send`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
campaignId,
|
||||
subject,
|
||||
fromName,
|
||||
fromEmail,
|
||||
replyTo: settings.defaultReplyTo ?? undefined,
|
||||
htmlBody,
|
||||
textBody,
|
||||
recipients: recipients.map((email) => ({ email })),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`bulk-send fehlgeschlagen (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
return (await res.json()) as WaveBulkSendResult;
|
||||
}
|
||||
163
apps/mana/apps/web/src/lib/modules/forms/lib/wave-scheduler.ts
Normal file
163
apps/mana/apps/web/src/lib/modules/forms/lib/wave-scheduler.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Wave-scheduler — webapp-side cron for recurring forms (M10c).
|
||||
*
|
||||
* Runs while the user has the Mana tab open. Every TICK_INTERVAL_MS,
|
||||
* scans the formTable for forms with recurrence configured + due
|
||||
* + recipients + an unlisted share-token, then fires the wave via
|
||||
* mana-mail's bulk-send endpoint. After a successful send, stamps
|
||||
* lastSentAt so the next tick skips the form for one full period.
|
||||
*
|
||||
* Headless cron via mana-ai or mana-notify is M10d — this version
|
||||
* only runs while the owner has Mana open. It still solves the
|
||||
* "I forgot to click the button" case for users who keep Mana open
|
||||
* during the work week.
|
||||
*
|
||||
* Page-visibility-aware: pauses while the tab is hidden so we don't
|
||||
* fire ticks on a backgrounded laptop with no auth refresh; resumes
|
||||
* on visibility-change.
|
||||
*
|
||||
* Singleton — starting twice is a no-op. start/stop are idempotent.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { decryptRecord, decryptRecords, isVaultUnlocked } from '$lib/data/crypto';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { settingsTable, BROADCAST_SETTINGS_ID } from '$lib/modules/broadcasts/queries';
|
||||
import { toSettings } from '$lib/modules/broadcasts/queries';
|
||||
import { buildShareUrl } from '@mana/shared-privacy';
|
||||
import { formTable } from '../collections';
|
||||
import { toForm } from '../queries';
|
||||
import { formsStore } from '../stores/forms.svelte';
|
||||
import { isWaveDue } from './wave';
|
||||
import { computeCohort } from './cohort';
|
||||
import { sendWaveViaBulkMail, WavePreconditionError } from './wave-mail';
|
||||
import type { LocalBroadcastSettings } from '$lib/modules/broadcasts/types';
|
||||
import type { Form, LocalForm } from '../types';
|
||||
|
||||
// 5 minutes — fast enough that a Monday-morning weekly wave fires
|
||||
// within a few minutes of the boundary, slow enough to stay invisible.
|
||||
const TICK_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
let started = false;
|
||||
let tickInFlight = false;
|
||||
|
||||
export function startWaveScheduler(): void {
|
||||
if (!browser) return;
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
scheduleNext();
|
||||
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
|
||||
// Fire once after a short delay so the user opening Mana on Monday
|
||||
// morning doesn't have to wait 5 minutes for the first scan.
|
||||
setTimeout(() => {
|
||||
void runTick();
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
export function stopWaveScheduler(): void {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
visibilityHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void runTick();
|
||||
}, TICK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function runTick(): Promise<void> {
|
||||
if (tickInFlight) return;
|
||||
if (!isVaultUnlocked()) return;
|
||||
const jwt = await authStore.getValidToken().catch(() => null);
|
||||
if (!jwt) return;
|
||||
|
||||
tickInFlight = true;
|
||||
try {
|
||||
const forms = await loadDueForms();
|
||||
if (forms.length === 0) return;
|
||||
|
||||
const settings = await loadBroadcastSettings();
|
||||
if (!settings) return; // No broadcasts settings → can't bulk-send
|
||||
|
||||
for (const form of forms) {
|
||||
await fireWaveForForm(form, settings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[forms-wave-scheduler] tick failed:', (err as Error).message);
|
||||
} finally {
|
||||
tickInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDueForms(): Promise<Form[]> {
|
||||
const raw = (await formTable.toArray()).filter(
|
||||
(f) => !f.deletedAt && f.status === 'published' && f.unlistedToken
|
||||
);
|
||||
if (raw.length === 0) return [];
|
||||
const decrypted = (await decryptRecords('forms', raw)) as LocalForm[];
|
||||
const forms = decrypted.map(toForm);
|
||||
return forms.filter(
|
||||
(f) =>
|
||||
!!f.settings.recurrence?.frequency &&
|
||||
(f.settings.recurrence?.recipientEmails?.length ?? 0) > 0 &&
|
||||
isWaveDue(f.settings.recurrence)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadBroadcastSettings(): Promise<ReturnType<typeof toSettings> | null> {
|
||||
const raw = await settingsTable.get(BROADCAST_SETTINGS_ID);
|
||||
if (!raw) return null;
|
||||
const decrypted = (await decryptRecord('broadcastSettings', {
|
||||
...raw,
|
||||
})) as LocalBroadcastSettings;
|
||||
return toSettings(decrypted);
|
||||
}
|
||||
|
||||
async function fireWaveForForm(form: Form, settings: ReturnType<typeof toSettings>): Promise<void> {
|
||||
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
|
||||
const shareUrl = buildShareUrl(origin, form.unlistedToken);
|
||||
const cohort = computeCohort(new Date().toISOString(), form.settings.recurrence!.frequency);
|
||||
|
||||
try {
|
||||
const result = await sendWaveViaBulkMail({
|
||||
form,
|
||||
shareUrl,
|
||||
settings,
|
||||
cohort,
|
||||
});
|
||||
await formsStore.markWaveSent(form.id);
|
||||
console.info(
|
||||
`[forms-wave-scheduler] wave fired for "${form.title}" — ${result.delivered}/${result.accepted} delivered`
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof WavePreconditionError) {
|
||||
// Settings-side gap (no fromEmail, no impressum, etc.). Log
|
||||
// once, don't keep retrying — user has to fix the precondition.
|
||||
console.warn(`[forms-wave-scheduler] precondition for "${form.title}": ${err.message}`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[forms-wave-scheduler] send failed for "${form.title}": ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,15 @@
|
|||
import SettingsPanel from '../components/SettingsPanel.svelte';
|
||||
import BranchingEditor from '../components/BranchingEditor.svelte';
|
||||
import { buildWaveMailto, isWaveDue, nextWaveDueAt } from '../lib/wave';
|
||||
import { sendWaveViaBulkMail, WavePreconditionError } from '../lib/wave-mail';
|
||||
import { computeCohort } from '../lib/cohort';
|
||||
import { decryptRecord } from '$lib/data/crypto';
|
||||
import {
|
||||
settingsTable,
|
||||
BROADCAST_SETTINGS_ID,
|
||||
toSettings,
|
||||
} from '$lib/modules/broadcasts/queries';
|
||||
import type { LocalBroadcastSettings, BroadcastSettings } from '$lib/modules/broadcasts/types';
|
||||
import {
|
||||
VisibilityPicker,
|
||||
SharedLinkControls,
|
||||
|
|
@ -161,11 +170,52 @@
|
|||
(recurrence.recipientEmails ?? []).length > 0
|
||||
);
|
||||
|
||||
let waveError = $state<string | null>(null);
|
||||
|
||||
async function sendWave() {
|
||||
if (!canSendWave || !shareUrl) return;
|
||||
const recipients = entry.settings.recurrence?.recipientEmails ?? [];
|
||||
if (recipients.length === 0) return;
|
||||
waveError = null;
|
||||
|
||||
// Try bulk-send first (M10c) — needs broadcasts settings configured.
|
||||
// Fallback to mailto bridge (M10b) if bulk-send preconditions miss.
|
||||
const bulkSettings = await loadBulkMailSettings();
|
||||
|
||||
if (bulkSettings) {
|
||||
const ok = confirm(
|
||||
$_('forms.builder.recurrence.confirmBulk', {
|
||||
default:
|
||||
'Welle an {n} Empfänger via Mana-Mail senden? Antworten landen direkt in deinem Forms-Inbox.',
|
||||
values: { n: recipients.length },
|
||||
})
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const cohort = computeCohort(
|
||||
new Date().toISOString(),
|
||||
entry.settings.recurrence!.frequency
|
||||
);
|
||||
await sendWaveViaBulkMail({
|
||||
form: entry,
|
||||
shareUrl,
|
||||
settings: bulkSettings,
|
||||
cohort,
|
||||
});
|
||||
await formsStore.markWaveSent(entry.id);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof WavePreconditionError) {
|
||||
// Fall through to mailto bridge — settings have a gap.
|
||||
waveError = err.message;
|
||||
} else {
|
||||
waveError = err instanceof Error ? err.message : 'Bulk-Send fehlgeschlagen.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mailto bridge path (M10b)
|
||||
const subject = $_('forms.builder.recurrence.mailSubject', {
|
||||
default: 'Bitte ausfüllen: {title}',
|
||||
values: { title: entry.title },
|
||||
|
|
@ -190,6 +240,19 @@
|
|||
await formsStore.markWaveSent(entry.id);
|
||||
}
|
||||
|
||||
async function loadBulkMailSettings(): Promise<BroadcastSettings | null> {
|
||||
const raw = await settingsTable.get(BROADCAST_SETTINGS_ID);
|
||||
if (!raw) return null;
|
||||
const decrypted = (await decryptRecord('broadcastSettings', {
|
||||
...raw,
|
||||
})) as LocalBroadcastSettings;
|
||||
const settings = toSettings(decrypted);
|
||||
if (!settings.defaultFromEmail?.trim() || !settings.legalAddress?.trim()) {
|
||||
return null; // missing precondition — caller falls back to mailto
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function formatDueAt(d: Date | null): string {
|
||||
if (!d) return '';
|
||||
return d.toLocaleString();
|
||||
|
|
@ -374,6 +437,9 @@
|
|||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if waveError}
|
||||
<p class="vis-error">{waveError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
import { initTools } from '$lib/data/tools/init';
|
||||
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
||||
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
||||
import { startWaveScheduler, stopWaveScheduler } from '$lib/modules/forms/lib/wave-scheduler';
|
||||
import { startGoalTracker, stopGoalTracker } from '$lib/companion/goals';
|
||||
import {
|
||||
startFeedbackToaster,
|
||||
|
|
@ -594,6 +595,11 @@
|
|||
// interval and runs any that are due. Safe idempotent; see
|
||||
// data/ai/missions/setup.ts.
|
||||
startMissionTick();
|
||||
// Forms wave-scheduler (M10c) — auto-fires due recurring-form
|
||||
// waves via mana-mail bulk-send while the tab is open. No-op
|
||||
// without broadcasts settings configured. Headless cron is
|
||||
// M10d (server-side worker in mana-ai or mana-notify).
|
||||
startWaveScheduler();
|
||||
// Apply server-planned iterations locally on sync — see
|
||||
// data/ai/missions/server-iteration-executor.ts.
|
||||
startServerIterationExecutor();
|
||||
|
|
@ -747,6 +753,7 @@
|
|||
stopGoalTracker();
|
||||
stopFeedbackToaster();
|
||||
stopMissionTick();
|
||||
stopWaveScheduler();
|
||||
stopServerIterationExecutor();
|
||||
guestMode?.destroy();
|
||||
// Fire-and-forget — we don't need to await; the in-flight task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue