feat(transfers): add PicTransfer — cross-event file transfers (#998)
Closes #997. Send original files from any event as a token-protected download link, with an optional client-upload channel. Strictly opt-in behind a new `transfers` feature flag, default OFF. Migrations 170-172 (transfers, transfer_files, transfer_extra_files, transfer_uploads, transfer_recipients, transfer_downloads, default settings and two email templates) — all hasTable/hasColumn-guarded and idempotent, with destructive statements confined to down(). Backend: transferService (CRUD, 256-bit download token, 6-char upload token, cross-event ZIP streaming of originals), admin CRUD routes, and two public token routes. transferCleanupService runs an hourly retention sweep; source-event photos are never touched. All three routers fail closed via requireFeatureFlag('transfers'). Review closed two ownership blockers, both the same root cause — permissions used where ownership was needed: - photoIds arrived from the request body and were validated only for existence, so a scoped admin could bundle any event's originals and hand them out through the public download token. filterOwnedPhotoIds now resolves ids to their events and gates them through filterOwnedEventIds, on both the create and add-files paths. - The transfer list was unscoped and carried each row's download token, so any admin with events.view could read another's token and fetch their originals. The list is now scoped by created_by, the token/url fields are stripped from the list payload, and a single router.use('/:id', requireTransferOwnership) covers all twelve /:id routes, 404ing foreign and missing alike. The admin photo picker filters its event list to the same rule, so the UI stops offering picks the API would discard. Fork-PR workflows had not been approved since the fix commits, so the PR's green checks were stale against the pre-fix head. Verified by dispatching tests.yml against the actual head: backend and frontend both green. Follow-up: neither ownership guard has a regression test yet. Co-authored-by: Luca-Timo <[email protected]>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Migration 170: PicTransfer — cross-event file transfers (#997).
|
||||
*
|
||||
* Adds the tables that back the "send these files to someone" feature:
|
||||
*
|
||||
* transfers One share link. Bundles photos picked from ANY event,
|
||||
* protected by a 64-hex recipient token. Optionally opens
|
||||
* a 6-char upload token so the client can send files back
|
||||
* (logos etc.). Disabled after `expires_at`; files are
|
||||
* kept `grace_days` days past disable, then hard-deleted.
|
||||
* transfer_files Join rows: which photos are in a transfer (cross-event).
|
||||
* photo_id → photos CASCADE, so removing the underlying
|
||||
* photo just drops it from the transfer; the reverse
|
||||
* (deleting a transfer) never touches the source photos.
|
||||
* transfer_uploads Files the client uploaded through the upload token.
|
||||
* These have their own bytes on disk (uploads/transfers/…)
|
||||
* and are what the retention sweep deletes.
|
||||
* transfer_downloads Lightweight audit of recipient downloads (count + IP).
|
||||
*
|
||||
* Downloads always serve ORIGINAL files (never watermarked) — a transfer is a
|
||||
* deliberate "here are your files" hand-off. Reuses the same original-file
|
||||
* resolution + archiver streaming as the gallery download-all path.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('transfers'))) {
|
||||
await knex.schema.createTable('transfers', (table) => {
|
||||
table.increments('id').primary();
|
||||
// Recipient download token — 64 hex chars = 32 bytes = 256 bits.
|
||||
table.string('token', 64).notNullable().unique();
|
||||
table.string('title', 255).notNullable().defaultTo('');
|
||||
table.text('message');
|
||||
table.integer('created_by').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
// Link is disabled once this passes (the "set time period" cap).
|
||||
table.timestamp('expires_at').notNullable();
|
||||
// Optional download cap. NULL or 0 = unlimited within the window.
|
||||
table.integer('max_downloads');
|
||||
table.integer('download_count').notNullable().defaultTo(0);
|
||||
table.boolean('is_active').notNullable().defaultTo(true);
|
||||
// When the link flipped inactive — starts the retention clock.
|
||||
table.timestamp('disabled_at');
|
||||
// Keep files this many days after disable, then hard-delete.
|
||||
table.integer('grace_days').notNullable().defaultTo(7);
|
||||
table.timestamp('admin_notified_at');
|
||||
table.timestamp('deleted_at');
|
||||
// Optional client-upload channel (6-char token).
|
||||
table.boolean('allow_uploads').notNullable().defaultTo(false);
|
||||
table.string('upload_token', 16).unique();
|
||||
table.timestamp('upload_expires_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.index(['is_active', 'expires_at'], 'transfers_active_expiry_idx');
|
||||
table.index(['deleted_at'], 'transfers_deleted_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('transfer_files'))) {
|
||||
await knex.schema.createTable('transfer_files', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('transfer_id').unsigned().notNullable()
|
||||
.references('id').inTable('transfers').onDelete('CASCADE');
|
||||
table.integer('photo_id').unsigned().notNullable()
|
||||
.references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.integer('sort_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.index(['transfer_id'], 'transfer_files_transfer_idx');
|
||||
// A photo can only appear once per transfer.
|
||||
table.unique(['transfer_id', 'photo_id'], 'transfer_files_unique');
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('transfer_uploads'))) {
|
||||
await knex.schema.createTable('transfer_uploads', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('transfer_id').unsigned().notNullable()
|
||||
.references('id').inTable('transfers').onDelete('CASCADE');
|
||||
table.string('original_filename', 512).notNullable();
|
||||
// Storage-relative key, e.g. uploads/transfers/{id}/{stored-name}.
|
||||
table.string('stored_path', 1024).notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.string('mime_type', 100);
|
||||
table.string('uploader_ip', 45);
|
||||
table.timestamp('uploaded_at').defaultTo(knex.fn.now());
|
||||
table.index(['transfer_id'], 'transfer_uploads_transfer_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('transfer_downloads'))) {
|
||||
await knex.schema.createTable('transfer_downloads', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('transfer_id').unsigned().notNullable()
|
||||
.references('id').inTable('transfers').onDelete('CASCADE');
|
||||
table.string('kind', 20).notNullable().defaultTo('all'); // 'all' | 'single'
|
||||
table.integer('photo_id').unsigned();
|
||||
table.string('ip', 45);
|
||||
table.timestamp('downloaded_at').defaultTo(knex.fn.now());
|
||||
table.index(['transfer_id'], 'transfer_downloads_transfer_idx');
|
||||
});
|
||||
}
|
||||
|
||||
// Defaults for the create-transfer form + retention/upload behaviour.
|
||||
const settings = [
|
||||
{ setting_key: 'transfer_default_expiry_days', setting_value: JSON.stringify(14), setting_type: 'number' },
|
||||
{ setting_key: 'transfer_default_grace_days', setting_value: JSON.stringify(7), setting_type: 'number' },
|
||||
{ setting_key: 'transfer_default_max_downloads', setting_value: JSON.stringify(0), setting_type: 'number' },
|
||||
{ setting_key: 'transfer_max_upload_size_mb', setting_value: JSON.stringify(50), setting_type: 'number' },
|
||||
{
|
||||
setting_key: 'transfer_upload_allowed_mime',
|
||||
setting_value: JSON.stringify([
|
||||
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
|
||||
'image/tiff', 'application/pdf', 'application/zip',
|
||||
]),
|
||||
setting_type: 'general',
|
||||
},
|
||||
];
|
||||
for (const s of settings) {
|
||||
const exists = await knex('app_settings').where('setting_key', s.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({ ...s, updated_at: knex.fn.now() });
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flag — PicTransfer is a strictly opt-in module like slideshow /
|
||||
// workflows: the sidebar entry, the /admin/transfers area and every
|
||||
// transfer route (admin + public) stay dark until an admin turns it on
|
||||
// under Settings → Features. Default OFF; idempotent seed.
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
const existingFlag = await knex('feature_flags').where({ key: 'transfers' }).first();
|
||||
if (!existingFlag) {
|
||||
await knex('feature_flags').insert({ key: 'transfers', value: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Admin notification when a transfer link expires (EN + DE, matching the
|
||||
// convention of the other admin-notification templates — see migration 087).
|
||||
const existingTemplate = await knex('email_templates')
|
||||
.where('template_key', 'transfer_link_expired')
|
||||
.first();
|
||||
if (!existingTemplate) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: 'transfer_link_expired',
|
||||
subject_en: 'A transfer link has expired — {{transfer_title}}',
|
||||
subject_de: 'Ein Transfer-Link ist abgelaufen — {{transfer_title}}',
|
||||
body_html_en: `
|
||||
<h2>A transfer link has expired</h2>
|
||||
|
||||
<p>The following file transfer is no longer downloadable by its recipient:</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Transfer:</strong> {{transfer_title}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Expired at:</strong> {{expiry_date}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Files included:</strong> {{file_count}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Client uploads received:</strong> {{upload_count}}</p>
|
||||
</div>
|
||||
|
||||
<p>The files will be kept for {{grace_days}} more days (until {{delete_date}})
|
||||
so you can re-share or retrieve anything you still need, then they are
|
||||
automatically deleted.</p>
|
||||
|
||||
<p><a href="{{admin_url}}">Open PicTransfer in the admin area</a></p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your PicPeak Installation</p>`,
|
||||
body_text_en: `A transfer link has expired
|
||||
|
||||
The following file transfer is no longer downloadable by its recipient:
|
||||
|
||||
Transfer: {{transfer_title}}
|
||||
Expired at: {{expiry_date}}
|
||||
Files included: {{file_count}}
|
||||
Client uploads received: {{upload_count}}
|
||||
|
||||
The files will be kept for {{grace_days}} more days (until {{delete_date}}) so
|
||||
you can re-share or retrieve anything you still need, then they are
|
||||
automatically deleted.
|
||||
|
||||
Open PicTransfer in the admin area: {{admin_url}}
|
||||
|
||||
Best regards,
|
||||
Your PicPeak Installation`,
|
||||
body_html_de: `
|
||||
<h2>Ein Transfer-Link ist abgelaufen</h2>
|
||||
|
||||
<p>Der folgende Datei-Transfer kann vom Empfänger nicht mehr heruntergeladen werden:</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Transfer:</strong> {{transfer_title}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Abgelaufen am:</strong> {{expiry_date}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Enthaltene Dateien:</strong> {{file_count}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Empfangene Kunden-Uploads:</strong> {{upload_count}}</p>
|
||||
</div>
|
||||
|
||||
<p>Die Dateien werden noch {{grace_days}} Tage aufbewahrt (bis {{delete_date}}),
|
||||
damit Sie alles Benötigte erneut teilen oder abrufen können; danach werden sie
|
||||
automatisch gelöscht.</p>
|
||||
|
||||
<p><a href="{{admin_url}}">PicTransfer im Admin-Bereich öffnen</a></p>
|
||||
|
||||
<p>Mit freundlichen Grüßen,<br>
|
||||
Ihre PicPeak-Installation</p>`,
|
||||
body_text_de: `Ein Transfer-Link ist abgelaufen
|
||||
|
||||
Der folgende Datei-Transfer kann vom Empfänger nicht mehr heruntergeladen werden:
|
||||
|
||||
Transfer: {{transfer_title}}
|
||||
Abgelaufen am: {{expiry_date}}
|
||||
Enthaltene Dateien: {{file_count}}
|
||||
Empfangene Kunden-Uploads: {{upload_count}}
|
||||
|
||||
Die Dateien werden noch {{grace_days}} Tage aufbewahrt (bis {{delete_date}}),
|
||||
danach werden sie automatisch gelöscht.
|
||||
|
||||
PicTransfer im Admin-Bereich öffnen: {{admin_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihre PicPeak-Installation`,
|
||||
variables: JSON.stringify([
|
||||
'transfer_title', 'expiry_date', 'file_count', 'upload_count',
|
||||
'grace_days', 'delete_date', 'admin_url',
|
||||
]),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
await knex('feature_flags').where({ key: 'transfers' }).del();
|
||||
}
|
||||
await knex('email_templates').where('template_key', 'transfer_link_expired').del();
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'transfer_default_expiry_days',
|
||||
'transfer_default_grace_days',
|
||||
'transfer_default_max_downloads',
|
||||
'transfer_max_upload_size_mb',
|
||||
'transfer_upload_allowed_mime',
|
||||
])
|
||||
.del();
|
||||
await knex.schema.dropTableIfExists('transfer_downloads');
|
||||
await knex.schema.dropTableIfExists('transfer_uploads');
|
||||
await knex.schema.dropTableIfExists('transfer_files');
|
||||
await knex.schema.dropTableIfExists('transfers');
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Migration 171: PicTransfer — admin-uploaded deliverable files + email delivery
|
||||
* (follow-up to #997).
|
||||
*
|
||||
* 170 shipped the base feature; this adds two things the create flow now needs:
|
||||
*
|
||||
* transfer_extra_files Files the ADMIN uploads straight into a transfer at
|
||||
* creation (or later), stored as the transfer's own
|
||||
* bytes under `transfers/{id}/files/…`. Unlike
|
||||
* transfer_files (which reference gallery `photos`),
|
||||
* these have no event/photo — they are the operator's
|
||||
* own attachments and are part of the recipient's
|
||||
* download alongside the picked event photos. Deleted
|
||||
* with the transfer (retention sweep / hard delete).
|
||||
* transfer_recipients When a transfer is delivered by email, the recipient
|
||||
* address(es) it was sent to (audit + "sent to …" in the
|
||||
* detail panel). CASCADE with the transfer.
|
||||
*
|
||||
* Plus `transfers.delivery_method` ('link' | 'email', default 'link') and a
|
||||
* recipient-facing `transfer_ready` email template (EN/DE).
|
||||
*
|
||||
* 170 is already applied on existing installs, so this is a separate, additive
|
||||
* migration (Knex won't re-run 170). Fully guarded + idempotent.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('transfer_extra_files'))) {
|
||||
await knex.schema.createTable('transfer_extra_files', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('transfer_id').unsigned().notNullable()
|
||||
.references('id').inTable('transfers').onDelete('CASCADE');
|
||||
table.string('original_filename', 512).notNullable();
|
||||
// Storage-relative key, e.g. transfers/{id}/files/{stored-name}.
|
||||
table.string('stored_path', 1024).notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.string('mime_type', 100);
|
||||
table.integer('sort_order').notNullable().defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.index(['transfer_id'], 'transfer_extra_files_transfer_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('transfer_recipients'))) {
|
||||
await knex.schema.createTable('transfer_recipients', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('transfer_id').unsigned().notNullable()
|
||||
.references('id').inTable('transfers').onDelete('CASCADE');
|
||||
table.string('email', 320).notNullable();
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('last_sent_at');
|
||||
table.index(['transfer_id'], 'transfer_recipients_transfer_idx');
|
||||
});
|
||||
}
|
||||
|
||||
const hasDeliveryMethod = await knex.schema.hasColumn('transfers', 'delivery_method');
|
||||
if (!hasDeliveryMethod) {
|
||||
await knex.schema.alterTable('transfers', (table) => {
|
||||
// 'link' (default — the operator copies/shares the link themselves) or
|
||||
// 'email' (PicPeak emailed the download link to transfer_recipients).
|
||||
table.string('delivery_method', 10).notNullable().defaultTo('link');
|
||||
});
|
||||
}
|
||||
|
||||
// Recipient-facing "your files are ready" email (EN + DE), sent when a
|
||||
// transfer is created with delivery_method='email'. Mirrors the convention
|
||||
// of the existing transfer_link_expired admin template (migration 170).
|
||||
const existingTemplate = await knex('email_templates')
|
||||
.where('template_key', 'transfer_ready')
|
||||
.first();
|
||||
if (!existingTemplate) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: 'transfer_ready',
|
||||
subject_en: 'Your files are ready — {{transfer_title}}',
|
||||
subject_de: 'Ihre Dateien sind bereit — {{transfer_title}}',
|
||||
body_html_en: `
|
||||
<h2>Your files are ready</h2>
|
||||
|
||||
<p>{{transfer_title}} has been shared with you.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;">{{message}}</p>
|
||||
</div>
|
||||
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="{{download_url}}" class="button">Download your files</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Files:</strong> {{file_count}}<br>
|
||||
<strong>Available until:</strong> {{expiry_date}}</p>
|
||||
|
||||
<p style="color: #888; font-size: 13px;">If the button doesn't work, copy this link into your browser:<br>{{download_url}}</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your PicPeak Installation</p>`,
|
||||
body_text_en: `Your files are ready
|
||||
|
||||
{{transfer_title}} has been shared with you.
|
||||
|
||||
{{message}}
|
||||
|
||||
Download your files: {{download_url}}
|
||||
|
||||
Files: {{file_count}}
|
||||
Available until: {{expiry_date}}
|
||||
|
||||
Best regards,
|
||||
Your PicPeak Installation`,
|
||||
body_html_de: `
|
||||
<h2>Ihre Dateien sind bereit</h2>
|
||||
|
||||
<p>{{transfer_title}} wurde mit Ihnen geteilt.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;">{{message}}</p>
|
||||
</div>
|
||||
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="{{download_url}}" class="button">Dateien herunterladen</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Dateien:</strong> {{file_count}}<br>
|
||||
<strong>Verfügbar bis:</strong> {{expiry_date}}</p>
|
||||
|
||||
<p style="color: #888; font-size: 13px;">Falls die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>{{download_url}}</p>
|
||||
|
||||
<p>Mit freundlichen Grüßen,<br>
|
||||
Ihre PicPeak-Installation</p>`,
|
||||
body_text_de: `Ihre Dateien sind bereit
|
||||
|
||||
{{transfer_title}} wurde mit Ihnen geteilt.
|
||||
|
||||
{{message}}
|
||||
|
||||
Dateien herunterladen: {{download_url}}
|
||||
|
||||
Dateien: {{file_count}}
|
||||
Verfügbar bis: {{expiry_date}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihre PicPeak-Installation`,
|
||||
variables: JSON.stringify([
|
||||
'transfer_title', 'message', 'download_url', 'file_count', 'expiry_date',
|
||||
]),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
await knex('email_templates').where('template_key', 'transfer_ready').del();
|
||||
if (await knex.schema.hasColumn('transfers', 'delivery_method')) {
|
||||
await knex.schema.alterTable('transfers', (table) => {
|
||||
table.dropColumn('delivery_method');
|
||||
});
|
||||
}
|
||||
await knex.schema.dropTableIfExists('transfer_recipients');
|
||||
await knex.schema.dropTableIfExists('transfer_extra_files');
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Migration 172: reword the recipient `transfer_ready` email (follow-up to 171).
|
||||
*
|
||||
* Two fixes to the copy seeded in 171:
|
||||
* 1. Warmer, less robotic wording (greeting + natural phrasing + friendly
|
||||
* sign-off) instead of the terse "has been shared with you" notice.
|
||||
* 2. The message block is wrapped in `{{#if message}}` so a transfer sent
|
||||
* WITHOUT a personal note no longer renders an empty coloured box (the
|
||||
* "grey bar" some clients showed for the always-present empty <div>).
|
||||
*
|
||||
* This is a content UPDATE rather than an edit to 171 because 171 has already
|
||||
* been applied on existing installs — Knex won't re-run it, so the seeded row
|
||||
* would otherwise keep the old copy. UPDATE reaches both existing rows and
|
||||
* fresh installs (which run 171's insert first, then this).
|
||||
*/
|
||||
|
||||
const HTML_EN = `
|
||||
<h2>Your files are ready</h2>
|
||||
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>{{transfer_title}} is ready for you — you can grab everything with a single click below.</p>
|
||||
|
||||
{{#if message}}
|
||||
<div style="background-color: #f6f6f4; border-left: 4px solid #5C8762; padding: 16px 20px; margin: 20px 0; border-radius: 4px; white-space: pre-line;">{{message}}</div>
|
||||
{{/if}}
|
||||
|
||||
<p style="margin: 28px 0;">
|
||||
<a href="{{download_url}}" class="button">Download your files</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #555555;">The link stays active until {{expiry_date}} and includes {{file_count}} file(s).</p>
|
||||
|
||||
<p style="color: #999999; font-size: 13px;">Button not working? Just copy this link into your browser:<br>{{download_url}}</p>
|
||||
|
||||
<p>Enjoy your photos!</p>`;
|
||||
|
||||
const TEXT_EN = `Your files are ready
|
||||
|
||||
Hi,
|
||||
|
||||
{{transfer_title}} is ready for you — grab everything with the link below.
|
||||
{{#if message}}
|
||||
|
||||
{{message}}
|
||||
{{/if}}
|
||||
|
||||
Download your files:
|
||||
{{download_url}}
|
||||
|
||||
The link stays active until {{expiry_date}} and includes {{file_count}} file(s).
|
||||
|
||||
Enjoy your photos!`;
|
||||
|
||||
const HTML_DE = `
|
||||
<h2>Ihre Dateien sind bereit</h2>
|
||||
|
||||
<p>Hallo,</p>
|
||||
|
||||
<p>{{transfer_title}} ist für Sie bereit — mit einem Klick unten können Sie alles herunterladen.</p>
|
||||
|
||||
{{#if message}}
|
||||
<div style="background-color: #f6f6f4; border-left: 4px solid #5C8762; padding: 16px 20px; margin: 20px 0; border-radius: 4px; white-space: pre-line;">{{message}}</div>
|
||||
{{/if}}
|
||||
|
||||
<p style="margin: 28px 0;">
|
||||
<a href="{{download_url}}" class="button">Dateien herunterladen</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #555555;">Der Link ist bis zum {{expiry_date}} gültig und enthält {{file_count}} Datei(en).</p>
|
||||
|
||||
<p style="color: #999999; font-size: 13px;">Funktioniert die Schaltfläche nicht? Kopieren Sie einfach diesen Link in Ihren Browser:<br>{{download_url}}</p>
|
||||
|
||||
<p>Viel Freude mit Ihren Fotos!</p>`;
|
||||
|
||||
const TEXT_DE = `Ihre Dateien sind bereit
|
||||
|
||||
Hallo,
|
||||
|
||||
{{transfer_title}} ist für Sie bereit — laden Sie alles über den Link unten herunter.
|
||||
{{#if message}}
|
||||
|
||||
{{message}}
|
||||
{{/if}}
|
||||
|
||||
Dateien herunterladen:
|
||||
{{download_url}}
|
||||
|
||||
Der Link ist bis zum {{expiry_date}} gültig und enthält {{file_count}} Datei(en).
|
||||
|
||||
Viel Freude mit Ihren Fotos!`;
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'transfer_ready')
|
||||
.update({
|
||||
subject_en: '{{transfer_title}} — your files are ready to download',
|
||||
subject_de: '{{transfer_title}} — Ihre Dateien stehen bereit',
|
||||
body_html_en: HTML_EN,
|
||||
body_text_en: TEXT_EN,
|
||||
body_html_de: HTML_DE,
|
||||
body_text_de: TEXT_DE,
|
||||
});
|
||||
};
|
||||
|
||||
// Content-only refresh — nothing structural to reverse. The previous copy is
|
||||
// preserved in migration 171's insert for reference.
|
||||
exports.down = async function () {};
|
||||
Reference in New Issue
Block a user