Merge pull request #337 from Luca-Timo/feat/workflow-engine
Feat/workflow engine
This commit is contained in:
@@ -415,6 +415,16 @@ describe('workflow engine', () => {
|
|||||||
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
|
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
|
||||||
|
const { _internal } = require('../../src/services/eventReminderService');
|
||||||
|
const p = _internal.composePayload({
|
||||||
|
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
|
||||||
|
recipientEmail: '[email protected]', daysBefore: 2, businessName: 'Biz',
|
||||||
|
});
|
||||||
|
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
|
||||||
|
expect(p.event_date).not.toMatch(/invalid/i);
|
||||||
|
});
|
||||||
|
|
||||||
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
|
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
|
||||||
const webhook = engine.registry.getAction('webhook');
|
const webhook = engine.registry.getAction('webhook');
|
||||||
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
|
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
|
||||||
@@ -450,6 +460,29 @@ describe('workflow engine', () => {
|
|||||||
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
|
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
|
||||||
|
const eventReminderService = require('../../src/services/eventReminderService');
|
||||||
|
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||||
|
const [custId] = await db('customer_accounts').insert({
|
||||||
|
email: '[email protected]', preferred_language: 'en', is_active: true, created_at: new Date(),
|
||||||
|
});
|
||||||
|
// Event with NO inline customer_email / host_email.
|
||||||
|
await db('events').insert({
|
||||||
|
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
|
||||||
|
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
|
||||||
|
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
|
||||||
|
});
|
||||||
|
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
|
||||||
|
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
|
||||||
|
|
||||||
|
const res = await eventReminderService.sendReminderForEvent(ev.id);
|
||||||
|
expect(res.sent).toBe(1);
|
||||||
|
const mail = await db('email_queue').where({ recipient_email: '[email protected]' }).first();
|
||||||
|
expect(mail).toBeTruthy();
|
||||||
|
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
|
||||||
|
expect(mail.event_id == null).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
|
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
|
||||||
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||||
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||||
|
|||||||
@@ -1189,6 +1189,22 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
created_at: new Date()
|
created_at: new Date()
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// No inline email, but the gallery may be assigned to registered customer
|
||||||
|
// account(s). Notify them via the account "your galleries" email
|
||||||
|
// (customer_gallery_assigned, in the customer's own language) instead of
|
||||||
|
// the gallery_created mail, which needs an inline recipient. Best-effort.
|
||||||
|
try {
|
||||||
|
const customerAccountsService = require('../services/customerAccountsService');
|
||||||
|
const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
|
||||||
|
for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) {
|
||||||
|
await customerAccountsService
|
||||||
|
.notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
|
||||||
|
.catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
|
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
|
||||||
|
|||||||
@@ -1456,6 +1456,7 @@ module.exports = {
|
|||||||
setAssignmentsForEvent,
|
setAssignmentsForEvent,
|
||||||
setAssignmentsForCustomer,
|
setAssignmentsForCustomer,
|
||||||
getAssignmentsForEvent,
|
getAssignmentsForEvent,
|
||||||
|
notifyCustomerOfNewAssignments,
|
||||||
listEventsForCustomer,
|
listEventsForCustomer,
|
||||||
customerHasAccessToEvent,
|
customerHasAccessToEvent,
|
||||||
getPendingInvitations,
|
getPendingInvitations,
|
||||||
|
|||||||
@@ -860,10 +860,19 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
|
|||||||
|
|
||||||
for (const email of pendingEmails) {
|
for (const email of pendingEmails) {
|
||||||
try {
|
try {
|
||||||
const emailData = typeof email.email_data === 'string'
|
const emailData = typeof email.email_data === 'string'
|
||||||
? JSON.parse(email.email_data || '{}')
|
? JSON.parse(email.email_data || '{}')
|
||||||
: email.email_data || {};
|
: email.email_data || {};
|
||||||
|
|
||||||
|
// Language is resolved from emailData.eventId (event.language is the top
|
||||||
|
// priority). queueEmail injects it, but direct email_queue inserts (e.g.
|
||||||
|
// the gallery-publish notification) only set the event_id COLUMN — so
|
||||||
|
// backfill from the authoritative column so every send path resolves the
|
||||||
|
// recipient language from the event consistently.
|
||||||
|
if (emailData.eventId == null && email.event_id != null) {
|
||||||
|
emailData.eventId = email.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
const sendResult = await sendTemplateEmail(
|
const sendResult = await sendTemplateEmail(
|
||||||
email.recipient_email,
|
email.recipient_email,
|
||||||
email.email_type,
|
email.email_type,
|
||||||
|
|||||||
@@ -103,18 +103,14 @@ function composePayload({ event, recipientEmail, daysBefore, businessName }) {
|
|||||||
|| event.host_name
|
|| event.host_name
|
||||||
|| recipientEmail
|
|| recipientEmail
|
||||||
|| '';
|
|| '';
|
||||||
// Event date formatted DD.MM.YYYY here for simplicity; the rendered
|
// Pass the RAW event_date — emailProcessor.processTemplate runs it through
|
||||||
// email may further re-locale via the template engine when locale-
|
// formatDate(value, recipientLanguage). Pre-formatting it (e.g. DD.MM.YYYY)
|
||||||
// aware formatters are introduced.
|
// makes the processor's new Date(...) reparse fail → "Invalid Date". Same
|
||||||
const ed = event.event_date instanceof Date ? event.event_date : new Date(event.event_date);
|
// contract the expiry mailer uses.
|
||||||
const day = String(ed.getUTCDate()).padStart(2, '0');
|
|
||||||
const month = String(ed.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const year = ed.getUTCFullYear();
|
|
||||||
const eventDateFormatted = `${day}.${month}.${year}`;
|
|
||||||
return {
|
return {
|
||||||
customer_name: customerName,
|
customer_name: customerName,
|
||||||
event_name: event.event_name || `Event #${event.id}`,
|
event_name: event.event_name || `Event #${event.id}`,
|
||||||
event_date: eventDateFormatted,
|
event_date: event.event_date || '',
|
||||||
event_type: event.event_type || '',
|
event_type: event.event_type || '',
|
||||||
days_before: daysBefore,
|
days_before: daysBefore,
|
||||||
business_name: businessName || '',
|
business_name: businessName || '',
|
||||||
@@ -191,8 +187,9 @@ async function runEventReminderPass() {
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
const recipientEmail = row.customer_email || row.host_email;
|
// Inline event email, else the assigned customer account(s).
|
||||||
if (!recipientEmail) { skipped += 1; continue; }
|
const recipients = await resolveReminderRecipients(row);
|
||||||
|
if (!recipients.length) { skipped += 1; continue; }
|
||||||
const rawOffset = row.event_reminder_offset_days;
|
const rawOffset = row.event_reminder_offset_days;
|
||||||
const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
|
const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
|
||||||
? Number(rawOffset)
|
? Number(rawOffset)
|
||||||
@@ -203,21 +200,18 @@ async function runEventReminderPass() {
|
|||||||
if (now < triggerAt) { skipped += 1; continue; }
|
if (now < triggerAt) { skipped += 1; continue; }
|
||||||
|
|
||||||
const templateKey = await resolveTemplateKey(row.event_type);
|
const templateKey = await resolveTemplateKey(row.event_type);
|
||||||
const payload = composePayload({
|
for (const r of recipients) {
|
||||||
event: row, recipientEmail, daysBefore: offsetDays, businessName,
|
const payload = composePayload({
|
||||||
});
|
event: row, recipientEmail: r.email, daysBefore: offsetDays, businessName,
|
||||||
// Per-event body override: when present, append as a synthetic
|
});
|
||||||
// `body_override` field. The template engine should branch on it
|
// Per-event body override rides through as a variable the template can branch on.
|
||||||
// (e.g. Handlebars `{{#if body_override}}{{body_override}}{{else}}…default body…{{/if}}`).
|
if (row.event_reminder_body_override) {
|
||||||
// For installs where the templates don't yet handle the branch,
|
payload.body_override = row.event_reminder_body_override;
|
||||||
// the override still rides through as a variable the admin can
|
}
|
||||||
// reference manually.
|
// Inline → event language; assigned account → customer's preferred language (no eventId).
|
||||||
if (row.event_reminder_body_override) {
|
await emailProcessor.queueEmail(r.fromEvent ? row.id : null, r.email, templateKey, payload);
|
||||||
payload.body_override = row.event_reminder_body_override;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload);
|
|
||||||
|
|
||||||
// Stamp sent_at immediately so a same-pass-re-entrancy (or a
|
// Stamp sent_at immediately so a same-pass-re-entrancy (or a
|
||||||
// crash between queueEmail and the update) doesn't double-send
|
// crash between queueEmail and the update) doesn't double-send
|
||||||
// on the next tick. The queueEmail call is itself idempotent at
|
// on the next tick. The queueEmail call is itself idempotent at
|
||||||
@@ -283,8 +277,17 @@ async function sendReminderForEvent(eventId, { templateGroup = null } = {}) {
|
|||||||
if (row.is_active === false || row.is_active === 0 || row.is_archived === true || row.is_archived === 1) {
|
if (row.is_active === false || row.is_active === 0 || row.is_archived === true || row.is_archived === 1) {
|
||||||
return { sent: 0, skipped: 1, reason: 'inactive' };
|
return { sent: 0, skipped: 1, reason: 'inactive' };
|
||||||
}
|
}
|
||||||
const recipientEmail = row.customer_email || row.host_email;
|
|
||||||
if (!recipientEmail) return { sent: 0, skipped: 1, reason: 'no_recipient' };
|
// Recipient resolution:
|
||||||
|
// - inline event email (customer_email / host_email) → send there, language
|
||||||
|
// follows the event (eventId passed);
|
||||||
|
// - else fall back to the assigned customer account(s) (event_customer_assignments)
|
||||||
|
// → send to each registered customer, honouring THEIR preferred_language
|
||||||
|
// (queued without eventId so the resolver uses the customer, not the event).
|
||||||
|
// The gallery-ready mail deliberately doesn't fall back to accounts, but a
|
||||||
|
// pre-event reminder should still reach an assigned customer.
|
||||||
|
const recipients = await resolveReminderRecipients(row);
|
||||||
|
if (!recipients.length) return { sent: 0, skipped: 1, reason: 'no_recipient' };
|
||||||
|
|
||||||
const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
|
const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
|
||||||
const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
|
const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
|
||||||
@@ -299,12 +302,43 @@ async function sendReminderForEvent(eventId, { templateGroup = null } = {}) {
|
|||||||
// The flow block chooses the template GROUP (blank → the default group); the
|
// The flow block chooses the template GROUP (blank → the default group); the
|
||||||
// exact template is still auto-picked by event type within that group.
|
// exact template is still auto-picked by event type within that group.
|
||||||
const templateKey = await resolveTemplateKey(row.event_type, templateGroup || DEFAULT_TEMPLATE_GROUP);
|
const templateKey = await resolveTemplateKey(row.event_type, templateGroup || DEFAULT_TEMPLATE_GROUP);
|
||||||
const payload = composePayload({ event: row, recipientEmail, daysBefore: offsetDays, businessName });
|
|
||||||
if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
|
|
||||||
|
|
||||||
await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload);
|
let sent = 0;
|
||||||
|
for (const r of recipients) {
|
||||||
|
const payload = composePayload({ event: row, recipientEmail: r.email, daysBefore: offsetDays, businessName });
|
||||||
|
if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
|
||||||
|
// Inline → pass eventId (event language). Assigned account → no eventId so
|
||||||
|
// the resolver picks the customer's preferred_language.
|
||||||
|
await emailProcessor.queueEmail(r.fromEvent ? row.id : null, r.email, templateKey, payload);
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() });
|
await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() });
|
||||||
return { sent: 1, skipped: 0, offsetDays };
|
return { sent, skipped: 0, offsetDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who receives the pre-event reminder for an event: the inline event email if
|
||||||
|
* present, otherwise the active assigned customer account(s). `fromEvent` flags
|
||||||
|
* which language path to use (event vs customer).
|
||||||
|
*/
|
||||||
|
async function resolveReminderRecipients(eventRow) {
|
||||||
|
const inline = eventRow.customer_email || eventRow.host_email;
|
||||||
|
if (inline) return [{ email: inline, fromEvent: true }];
|
||||||
|
|
||||||
|
const assigned = await db('event_customer_assignments as a')
|
||||||
|
.join('customer_accounts as c', 'c.id', 'a.customer_account_id')
|
||||||
|
.where('a.event_id', eventRow.id)
|
||||||
|
.where('c.is_active', true)
|
||||||
|
.whereNotNull('c.email')
|
||||||
|
.select('c.email');
|
||||||
|
// De-dup emails defensively (a customer assigned twice, etc.).
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const a of assigned) {
|
||||||
|
const e = String(a.email).toLowerCase();
|
||||||
|
if (!seen.has(e)) { seen.add(e); out.push({ email: a.email, fromEvent: false }); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface PublishGalleryDialogProps {
|
|||||||
eventName: string;
|
eventName: string;
|
||||||
requirePassword: boolean;
|
requirePassword: boolean;
|
||||||
customerEmail?: string | null;
|
customerEmail?: string | null;
|
||||||
|
/** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */
|
||||||
|
assignedCustomerCount?: number;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
onConfirm: (password?: string) => void;
|
onConfirm: (password?: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -28,24 +30,33 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
|||||||
eventName,
|
eventName,
|
||||||
requirePassword,
|
requirePassword,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
|
assignedCustomerCount = 0,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Someone gets notified if there's an inline email OR an assigned account
|
||||||
|
// (the latter via the account "your galleries" email).
|
||||||
|
const willNotify = !!customerEmail || assignedCustomerCount > 0;
|
||||||
|
// The password is only collected (and required) on the inline-email path,
|
||||||
|
// because the gallery_created email carries it. With no inline email the field
|
||||||
|
// is hidden and the existing hash is kept — so don't gate submit on it, or a
|
||||||
|
// password-protected gallery without an email could never be published.
|
||||||
|
const needsPassword = requirePassword && !!customerEmail;
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (requirePassword) {
|
if (needsPassword) {
|
||||||
if (!password || password.trim().length < 6) {
|
if (!password || password.trim().length < 6) {
|
||||||
setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.'));
|
setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
onConfirm(requirePassword ? password : undefined);
|
onConfirm(needsPassword ? password : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,14 +83,21 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
|||||||
defaultValue:
|
defaultValue:
|
||||||
'Publishing "{{eventName}}" makes the gallery accessible and sends the notification email to {{customerEmail}}.',
|
'Publishing "{{eventName}}" makes the gallery accessible and sends the notification email to {{customerEmail}}.',
|
||||||
})
|
})
|
||||||
: t('events.publishDialog.descriptionNoEmail', {
|
: assignedCustomerCount > 0
|
||||||
eventName,
|
? t('events.publishDialog.descriptionAssignedAccount', {
|
||||||
defaultValue:
|
eventName,
|
||||||
'Publishing "{{eventName}}" makes the gallery accessible. No customer email is set, so no notification will be sent.',
|
count: assignedCustomerCount,
|
||||||
})}
|
defaultValue:
|
||||||
|
'Publishing "{{eventName}}" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.',
|
||||||
|
})
|
||||||
|
: t('events.publishDialog.descriptionNoEmail', {
|
||||||
|
eventName,
|
||||||
|
defaultValue:
|
||||||
|
'Publishing "{{eventName}}" makes the gallery accessible. No customer email is set, so no notification will be sent.',
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{requirePassword && customerEmail && (
|
{needsPassword && (
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
@@ -133,9 +151,9 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
isLoading={isPublishing}
|
isLoading={isPublishing}
|
||||||
leftIcon={<Send className="w-4 h-4" />}
|
leftIcon={willNotify ? <Send className="w-4 h-4" /> : undefined}
|
||||||
>
|
>
|
||||||
{t('events.publishAndNotify')}
|
{willNotify ? t('events.publishAndNotify') : t('events.publishDialog.justPublish', 'Publish')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1125,6 +1125,8 @@
|
|||||||
"title": "Galerie veröffentlichen",
|
"title": "Galerie veröffentlichen",
|
||||||
"descriptionWithEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht und die Benachrichtigungs-E-Mail an {{customerEmail}} gesendet.",
|
"descriptionWithEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht und die Benachrichtigungs-E-Mail an {{customerEmail}} gesendet.",
|
||||||
"descriptionNoEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht. Es ist keine Kunden-E-Mail hinterlegt – es wird keine Benachrichtigung gesendet.",
|
"descriptionNoEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht. Es ist keine Kunden-E-Mail hinterlegt – es wird keine Benachrichtigung gesendet.",
|
||||||
|
"descriptionAssignedAccount": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht. Die zugewiesenen Kundenkonten werden per E-Mail (in ihrer Sprache) benachrichtigt, dass sie verfügbar ist.",
|
||||||
|
"justPublish": "Veröffentlichen",
|
||||||
"passwordLabel": "Galerie-Passwort",
|
"passwordLabel": "Galerie-Passwort",
|
||||||
"passwordPlaceholder": "Galerie-Passwort eingeben",
|
"passwordPlaceholder": "Galerie-Passwort eingeben",
|
||||||
"passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.",
|
"passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.",
|
||||||
|
|||||||
@@ -670,6 +670,8 @@
|
|||||||
"title": "Publish gallery",
|
"title": "Publish gallery",
|
||||||
"descriptionWithEmail": "Publishing \"{{eventName}}\" makes the gallery accessible and sends the notification email to {{customerEmail}}.",
|
"descriptionWithEmail": "Publishing \"{{eventName}}\" makes the gallery accessible and sends the notification email to {{customerEmail}}.",
|
||||||
"descriptionNoEmail": "Publishing \"{{eventName}}\" makes the gallery accessible. No customer email is set, so no notification will be sent.",
|
"descriptionNoEmail": "Publishing \"{{eventName}}\" makes the gallery accessible. No customer email is set, so no notification will be sent.",
|
||||||
|
"descriptionAssignedAccount": "Publishing \"{{eventName}}\" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.",
|
||||||
|
"justPublish": "Publish",
|
||||||
"passwordLabel": "Gallery password",
|
"passwordLabel": "Gallery password",
|
||||||
"passwordPlaceholder": "Enter the gallery password",
|
"passwordPlaceholder": "Enter the gallery password",
|
||||||
"passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.",
|
"passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.",
|
||||||
|
|||||||
@@ -2661,6 +2661,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
eventName={event.event_name}
|
eventName={event.event_name}
|
||||||
requirePassword={isGalleryPublic(event) ? false : true}
|
requirePassword={isGalleryPublic(event) ? false : true}
|
||||||
customerEmail={event.customer_email}
|
customerEmail={event.customer_email}
|
||||||
|
assignedCustomerCount={((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts || []).length}
|
||||||
isPublishing={publishMutation.isPending}
|
isPublishing={publishMutation.isPending}
|
||||||
onConfirm={(password) => publishMutation.mutate(password)}
|
onConfirm={(password) => publishMutation.mutate(password)}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user