diff --git a/contributing.md b/contributing.md index 91fd3a4..f9690de 100644 --- a/contributing.md +++ b/contributing.md @@ -18,6 +18,10 @@ Emails use the Cloudflare `SEND_EMAIL` Worker binding. `wrangler dev` simulates 5. run migrations 6. `pnpm dev` +## UI documentation + +See [app screenshots](docs/screenshots/README.md) for examples of the current interface, starting with invoice and reminder emails. Run `pnpm docs:screenshots` to refresh these captures with sample data. When documenting another screen, add its screenshot and reproduction instructions there. + ## Database use turso cloud for database to develop locally. You can also use local sqlite database, but it's not recommended as some features are not supported for local database. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..ae7b764 --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,30 @@ +# App screenshots + +These screenshots document the actual UI for contributors. They use synthetic fixture data, not customer invoices or a production account. The application interface is Czech; the email language can be changed independently. + +## Invoice email composer + +Open an invoice and choose **Odeslat fakturu**. Recipient and email language default to the contact. The subject and body are editable; changing the language affects only this email. A share link is appended when sending. + +![Invoice email composer with a Czech template](invoice-email-cs.png) + +## Payment reminder composer + +Choose **Odeslat upomínku** on an unpaid invoice. This example uses an English-speaking contact, so the reminder is prefilled in English. + +![Payment reminder composer with an English template](invoice-reminder-en.png) + +## Refreshing the screenshots + +From the repository root after installing dependencies: + +```sh +pnpm exec playwright install chromium +pnpm docs:screenshots +``` + +The dedicated [Playwright configuration](../../playwright.docs.config.ts) starts the frontend and runs the [mocked email composer workflow](../../e2e/invoice-email.spec.ts). It needs no backend or database and sends no real email. Normal E2E runs do not overwrite these images. If a development frontend is already running on port 5173, it is reused. + +Captures use Chromium, a 1440 × 1100 viewport, 1× scale, and light mode. For documentation only, the capture helper expands the message field when necessary to show the full draft; it does not alter the app's default layout. Images are cropped to the dialog to keep the documentation focused. Inspect the PNGs after regenerating them and commit them alongside the corresponding UI changes. + +For future screens, use descriptive, stable filenames in this folder, add a short explanation here, and provide a reproducible capture using synthetic data. Never include personal data, credentials, real share links, or customer details. diff --git a/docs/screenshots/invoice-email-cs.png b/docs/screenshots/invoice-email-cs.png new file mode 100644 index 0000000..aae2a2a Binary files /dev/null and b/docs/screenshots/invoice-email-cs.png differ diff --git a/docs/screenshots/invoice-reminder-en.png b/docs/screenshots/invoice-reminder-en.png new file mode 100644 index 0000000..6131387 Binary files /dev/null and b/docs/screenshots/invoice-reminder-en.png differ diff --git a/e2e/invoice-email.spec.ts b/e2e/invoice-email.spec.ts new file mode 100644 index 0000000..8b954e4 --- /dev/null +++ b/e2e/invoice-email.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from './fixtures' +import { mkdir } from 'node:fs/promises' +import { resolve } from 'node:path' + +test('invoice email composer uses the contact language, preserves edits and sends reminders', async ({ + page +}, testInfo) => { + const captureComposer = async (filename: string) => { + if (!testInfo.project.metadata.documentationScreenshots) return + const directory = resolve('docs/screenshots') + await mkdir(directory, { recursive: true }) + await page.evaluate(() => document.fonts.ready) + // Expand only for documentation so the complete draft is visible. + const textarea = page + .getByRole('dialog') + .getByLabel('Zpráva', { exact: true }) + await textarea.evaluate((element) => { + if (element.scrollHeight > element.clientHeight) { + element.style.height = `${element.scrollHeight + 24}px` + } + }) + await page.getByRole('dialog').getByRole('heading').click() + await page.getByRole('dialog').screenshot({ + path: resolve(directory, filename), + animations: 'disabled', + caret: 'hide' + }) + } + await page.addInitScript(() => { + localStorage.setItem('auth_token', 'email-composer-test-token') + localStorage.setItem( + 'auth_user', + JSON.stringify({ + id: 'email-test-user', + name: 'Seller', + email: 'seller@example.com' + }) + ) + }) + const invoice = { + id: 'email-test-invoice', + number: '2026-123', + language: 'cs', + your_name: 'Seller', + your_street: 'Street 1', + your_city: 'Prague', + your_zip: '11000', + your_country: 'CZ', + your_registration_no: '12345678', + your_vat_no: '', + client_name: 'Client', + client_street: 'Street 2', + client_city: 'London', + client_zip: '12345', + client_country: 'GB', + issued_on: '2026-09-01', + taxable_fulfillment_due: '2026-09-01', + due_on: '2026-09-15', + due_in_days: 14, + bank_account: '123456789/0100', + currency: 'CZK', + total: 100, + paid_on: null, + status: null, + cancelled_at: null, + sent_at: null, + reminder_sent_at: null, + items: [ + { + id: 1, + description: 'Work', + quantity: 1, + unit_price: 100, + vat_rate: 0, + unit: 'ks' + } + ] + } + const sends: Record[] = [] + let failSend = false + await page.route('**/trpc/**', async (route) => { + const url = new URL(route.request().url()) + const procedures = url.pathname.split('/trpc/')[1].split(',') + const inputs = JSON.parse( + route.request().postData() || url.searchParams.get('input') || '{}' + ) + const response = procedures.map((procedure, index) => { + let json: unknown = null + if (procedure === 'invoices.getById') json = invoice + if (procedure === 'invoicingDetails') + json = { name: 'Seller', vat_payer: false, bankAccounts: [] } + if (procedure === 'invoices.listShares') json = [] + if (procedure === 'invoices.getEmailDraft') + json = { + to: 'client@example.com', + language: 'en', + number: invoice.number, + dueOn: invoice.due_on, + senderName: 'Seller' + } + if (procedure === 'invoices.sendEmail') { + if (failSend) + return { + error: { + json: { + message: 'E-mail se nepodařilo odeslat.', + code: -32603, + data: { + code: 'INTERNAL_SERVER_ERROR', + httpStatus: 500, + path: procedure + } + } + } + } + sends.push(inputs[index].json) + json = { sentAt: '2026-09-21' } + } + return { result: { data: { json } } } + }) + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + await page.goto('http://localhost:5173/invoices/email-test-invoice') + await page + .getByRole('button', { name: 'Odeslat fakturu', exact: true }) + .click() + const dialog = page.getByRole('dialog') + const language = dialog.getByLabel('Jazyk e-mailu', { exact: true }) + const subject = dialog.getByLabel('Předmět', { exact: true }) + const body = dialog.getByLabel('Zpráva', { exact: true }) + await expect(language).toHaveValue('en') + await expect(subject).toHaveValue('Invoice 2026-123') + await expect(dialog.getByLabel('Příjemce')).toHaveValue('client@example.com') + await language.selectOption('cs') + await expect(subject).toHaveValue('Faktura 2026-123') + await captureComposer('invoice-email-cs.png') + await body.fill('Vlastní zpráva') + page.once('dialog', (confirmation) => confirmation.dismiss()) + await language.selectOption('en') + await expect(language).toHaveValue('cs') + await expect(body).toHaveValue('Vlastní zpráva') + page.once('dialog', (confirmation) => confirmation.accept()) + await language.selectOption('en') + await expect(body).toHaveValue(/Hello,/) + await subject.fill('Custom subject') + await body.fill('Please review my invoice.') + await dialog.getByLabel('Příjemce').fill('other@example.com') + // A background query refetch must not reset the draft. + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await expect(body).toHaveValue('Please review my invoice.') + failSend = true + await dialog + .getByRole('button', { name: 'Odeslat e-mail', exact: true }) + .click() + await expect( + page.getByText('E-mail se nepodařilo odeslat.').first() + ).toBeVisible() + await expect(body).toHaveValue('Please review my invoice.') + failSend = false + await dialog + .getByRole('button', { name: 'Odeslat e-mail', exact: true }) + .click() + await expect(dialog).not.toBeVisible() + expect(sends).toEqual([ + { + invoiceId: invoice.id, + kind: 'invoice', + language: 'en', + to: 'other@example.com', + subject: 'Custom subject', + body: 'Please review my invoice.' + } + ]) + await page + .getByRole('button', { name: 'Odeslat upomínku', exact: true }) + .click() + await expect(subject).toHaveValue('Payment reminder – invoice 2026-123') + await expect(body).toHaveValue(/remains unpaid/) + await captureComposer('invoice-reminder-en.png') + await dialog + .getByRole('button', { name: 'Odeslat e-mail', exact: true }) + .click() + await expect(dialog).not.toBeVisible() + expect(sends[1]).toMatchObject({ kind: 'reminder', language: 'en' }) +}) diff --git a/faktorio-api/.dev.vars.e2e b/faktorio-api/.dev.vars.e2e index 69dc7dc..1756a20 100644 --- a/faktorio-api/.dev.vars.e2e +++ b/faktorio-api/.dev.vars.e2e @@ -2,6 +2,7 @@ TURSO_DATABASE_URL=http://127.0.0.1:8080 TURSO_AUTH_TOKEN=local JWT_SECRET=e2e-jwt-secret-not-used-anywhere-real-32-chars OPENROUTER_API_KEY=e2e-test-openrouter-key +PUBLIC_APP_URL=http://localhost:5173 VAPID_PUBLIC_KEY=BGs37P8xLBBboVi_dD2JWT6y8Kauh1iiXtxrs6tpy2edZCzYixA8BA6iQhm2rFBH7SSgR5xKHF23BkXkRWMS-fQ VAPID_PRIVATE_KEY=e2e-test-vapid-private-key VAPID_SUBJECT=mailto:e2e@test.local diff --git a/faktorio-api/.dev.vars.example b/faktorio-api/.dev.vars.example index 1c4dca4..e81c895 100644 --- a/faktorio-api/.dev.vars.example +++ b/faktorio-api/.dev.vars.example @@ -2,4 +2,5 @@ TURSO_DATABASE_URL= TURSO_AUTH_TOKEN= JWT_SECRET= OPENROUTER_API_KEY= +PUBLIC_APP_URL=http://localhost:5173 INVOICE_LOGO_PUBLIC_BASE_URL=https://uploads.faktorio.cz diff --git a/faktorio-api/src/envSchema.ts b/faktorio-api/src/envSchema.ts index e09355d..b7f302d 100644 --- a/faktorio-api/src/envSchema.ts +++ b/faktorio-api/src/envSchema.ts @@ -12,6 +12,7 @@ export const envSchema = z.object({ OPENROUTER_API_KEY: z.string().min(1), // Supplied by Wrangler, not process.env; absent in browser-only local mode. SEND_EMAIL: z.custom().optional(), + PUBLIC_APP_URL: z.url().optional(), VAPID_PUBLIC_KEY: z.string().min(1), VAPID_PRIVATE_KEY: z.string().min(1), VAPID_SUBJECT: z.string().min(1), diff --git a/faktorio-api/src/routers/invoices/invoiceEmail.spec.ts b/faktorio-api/src/routers/invoices/invoiceEmail.spec.ts new file mode 100644 index 0000000..55f4bb8 --- /dev/null +++ b/faktorio-api/src/routers/invoices/invoiceEmail.spec.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createClient, type Client } from '@libsql/client' +import { drizzle } from 'drizzle-orm/libsql' +import { migrate } from 'drizzle-orm/libsql/migrator' +import { eq } from 'drizzle-orm' +import * as schema from 'faktorio-db/schema' +import { invoiceRouter } from './invoiceRouter' +import type { TrpcContext } from '../../trpcContext' +import { invoiceEmailTemplate } from 'faktorio-shared/src/invoiceEmail' + +let client: Client +let ctx: TrpcContext +const sendEmail = vi.fn() +const input = { + invoiceId: 'invoice-1', + kind: 'invoice' as const, + language: 'en' as const, + to: 'chosen@example.com', + subject: 'Custom subject', + body: 'Hello & team!\nPlease pay.' +} + +beforeEach(async () => { + client = createClient({ url: ':memory:' }) + const db = drizzle(client, { schema }) + await migrate(db, { migrationsFolder: '../faktorio-db/drizzle' }) + const [user] = await db + .insert(schema.userT) + .values({ id: 'owner', email: 'owner@example.com', name: 'Owner' }) + .returning() + await db + .insert(schema.contactTb) + .values({ + id: 'contact-1', + user_id: user.id, + name: 'Client', + language: 'en', + main_email: 'contact@example.com' + }) + await db.insert(schema.invoicesTb).values({ + id: 'invoice-1', + user_id: user.id, + client_contact_id: 'contact-1', + number: '2026-001', + language: 'cs', + your_name: 'Seller', + your_street: '', + your_city: '', + your_zip: '', + your_country: 'CZ', + your_registration_no: '', + your_vat_no: '', + client_name: 'Client', + client_street: '', + client_city: '', + issued_on: '2026-09-01', + taxable_fulfillment_due: '2026-09-01', + due_on: '2026-09-15', + due_in_days: 14, + payment_method: 'bank', + currency: 'CZK', + total: 100, + native_subtotal: 100 + }) + sendEmail.mockReset().mockResolvedValue(undefined) + ctx = { + db, + user, + env: { PUBLIC_APP_URL: 'https://app.example.com' } as TrpcContext['env'], + req: new Request('https://untrusted.example.com'), + sendEmail, + generateToken: vi.fn() + } +}) + +afterEach(() => client.close()) + +describe('invoice email', () => { + it('prefills from the contact language and address, without creating a public share', async () => { + const draft = await invoiceRouter + .createCaller(ctx) + .getEmailDraft({ invoiceId: 'invoice-1', kind: 'invoice' }) + expect(draft).toMatchObject({ + to: 'contact@example.com', + language: 'en', + number: '2026-001', + senderName: 'Seller' + }) + expect(invoiceEmailTemplate(draft, draft.language, 'invoice').subject).toBe( + 'Invoice 2026-001' + ) + expect(await ctx.db.query.invoiceShareTb.findMany()).toHaveLength(0) + }) + + it('sends edited content with a working share, escapes HTML and only changes the sent date', async () => { + await invoiceRouter.createCaller(ctx).sendEmail(input) + const [share] = await ctx.db.query.invoiceShareTb.findMany() + expect(share).toMatchObject({ + invoice_id: 'invoice-1', + user_id: 'owner', + disabled_at: null + }) + expect(sendEmail).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + to: { email: 'chosen@example.com', name: 'Client' }, + subject: input.subject, + replyTo: { email: 'owner@example.com', name: 'Seller' }, + text: `${input.body}\n\nView and download invoice: https://app.example.com/shared-invoice/${share.id}` + }) + ) + expect(sendEmail.mock.calls[0][0].html).toContain( + 'Hello <client> & team!
Please pay.' + ) + const invoice = await ctx.db.query.invoicesTb.findFirst() + expect(invoice).toMatchObject({ language: 'cs', reminder_sent_at: null }) + expect(invoice?.sent_at).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect((await ctx.db.query.contactTb.findFirst())?.language).toBe('en') + }) + + it('records reminders separately and uses the selected email language', async () => { + await invoiceRouter + .createCaller(ctx) + .sendEmail({ ...input, kind: 'reminder', language: 'cs' }) + expect(sendEmail.mock.calls[0][0].text).toContain( + 'Zobrazit a stáhnout fakturu:' + ) + const invoice = await ctx.db.query.invoicesTb.findFirst() + expect(invoice?.reminder_sent_at).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(invoice?.sent_at).toBeNull() + }) + + it('does not mark failed emails sent and removes their unused public share', async () => { + sendEmail.mockRejectedValue(new Error('Provider failure')) + await expect( + invoiceRouter.createCaller(ctx).sendEmail(input) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }) + expect(await ctx.db.query.invoiceShareTb.findMany()).toHaveLength(0) + expect(await ctx.db.query.invoicesTb.findFirst()).toMatchObject({ + sent_at: null, + reminder_sent_at: null + }) + }) + + it('rejects access to another user’s invoice before creating shares or sending', async () => { + const caller = invoiceRouter.createCaller({ + ...ctx, + user: { ...ctx.user!, id: 'someone-else' } + }) + await expect( + caller.getEmailDraft({ invoiceId: 'invoice-1', kind: 'invoice' }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + await expect(caller.sendEmail(input)).rejects.toMatchObject({ + code: 'NOT_FOUND' + }) + expect(sendEmail).not.toHaveBeenCalled() + expect(await ctx.db.query.invoiceShareTb.findMany()).toHaveLength(0) + }) + + it('requires authentication', async () => { + await expect( + invoiceRouter.createCaller({ ...ctx, user: undefined }).sendEmail(input) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + expect(sendEmail).not.toHaveBeenCalled() + }) + + it('rejects paid reminders and cancelled invoices', async () => { + await ctx.db + .update(schema.invoicesTb) + .set({ paid_on: '2026-09-10' }) + .where(eq(schema.invoicesTb.id, 'invoice-1')) + await expect( + invoiceRouter.createCaller(ctx).sendEmail({ ...input, kind: 'reminder' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + await ctx.db.update(schema.invoicesTb).set({ cancelled_at: '2026-09-11' }) + await expect( + invoiceRouter.createCaller(ctx).sendEmail(input) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + expect(sendEmail).not.toHaveBeenCalled() + }) + + it.each([ + { to: 'invalid' }, + { subject: 'Hello\r\nBcc: other@example.com' }, + { body: ' ' } + ])('rejects invalid email input %j', async (invalid) => { + await expect( + invoiceRouter.createCaller(ctx).sendEmail({ ...input, ...invalid }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + expect(sendEmail).not.toHaveBeenCalled() + }) +}) diff --git a/faktorio-api/src/routers/invoices/invoiceEmail.ts b/faktorio-api/src/routers/invoices/invoiceEmail.ts new file mode 100644 index 0000000..adce0e4 --- /dev/null +++ b/faktorio-api/src/routers/invoices/invoiceEmail.ts @@ -0,0 +1,150 @@ +import { TRPCError } from '@trpc/server' +import { and, eq } from 'drizzle-orm' +import { + contactTb, + invoicesTb, + invoiceShareTb, + userInvoicingDetailsTb +} from 'faktorio-db/schema' +import { djs } from 'faktorio-shared/src/djs' +import { invoiceEmailLanguage } from 'faktorio-shared/src/invoiceEmail' +import { z } from 'zod/v4' +import { protectedProc } from '../../isAuthorizedMiddleware' +import type { TrpcContext } from '../../trpcContext' + +const emailInput = z.object({ + invoiceId: z.string().min(1), + kind: z.enum(['invoice', 'reminder']) +}) + +async function ownedInvoice(ctx: TrpcContext, invoiceId: string) { + const invoice = await ctx.db.query.invoicesTb.findFirst({ + where: and( + eq(invoicesTb.id, invoiceId), + eq(invoicesTb.user_id, ctx.user!.id) + ) + }) + if (!invoice) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Faktura nebyla nalezena.' + }) + return invoice +} + +function checkSendable( + invoice: typeof invoicesTb.$inferSelect, + kind: 'invoice' | 'reminder' +) { + if (invoice.cancelled_at) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Zrušenou fakturu nelze odeslat.' + }) + if (kind === 'reminder' && (invoice.paid_on || invoice.status === 'paid')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Zaplacenou fakturu nelze upomínat.' + }) + } +} + +export const getEmailDraft = protectedProc + .input(emailInput) + .query(async ({ ctx, input }) => { + const invoice = await ownedInvoice(ctx, input.invoiceId) + checkSendable(invoice, input.kind) + const contact = await ctx.db.query.contactTb.findFirst({ + where: and( + eq(contactTb.id, invoice.client_contact_id), + eq(contactTb.user_id, ctx.user.id) + ) + }) + return { + to: contact?.main_email || contact?.email || invoice.client_email || '', + language: invoiceEmailLanguage(contact?.language, invoice.language), + number: invoice.number, + dueOn: invoice.due_on, + senderName: invoice.your_name + } + }) + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[char]! + ) + +export const sendInvoiceEmail = protectedProc + .input( + emailInput.extend({ + to: z.string().trim().email().max(254), + language: z.enum(['cs', 'en']), + subject: z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[^\r\n]+$/), + body: z.string().trim().min(1).max(20000) + }) + ) + .mutation(async ({ ctx, input }) => { + const invoice = await ownedInvoice(ctx, input.invoiceId) + checkSendable(invoice, input.kind) + const details = await ctx.db.query.userInvoicingDetailsTb.findFirst({ + where: eq(userInvoicingDetailsTb.user_id, ctx.user.id) + }) + const replyTo = z.email().safeParse(details?.main_email || ctx.user.email) + // Use a configured origin, never a client-supplied URL or Host header. + const base = new URL(ctx.env.PUBLIC_APP_URL ?? 'https://faktorio.cz') + const [share] = await ctx.db + .insert(invoiceShareTb) + .values({ + invoice_id: invoice.id, + user_id: ctx.user.id + }) + .returning() + const link = new URL(`/shared-invoice/${share.id}`, base).href + const linkLabel = + input.language === 'cs' + ? 'Zobrazit a stáhnout fakturu' + : 'View and download invoice' + try { + await ctx.sendEmail({ + to: { email: input.to, name: invoice.client_name }, + subject: input.subject, + text: `${input.body}\n\n${linkLabel}: ${link}`, + html: `
${escapeHtml(input.body).replace(/\r?\n/g, '
')}

${linkLabel}

`, + ...(replyTo.success + ? { replyTo: { email: replyTo.data, name: invoice.your_name } } + : {}) + }) + } catch { + // A failed send must not leave an unused public share or mark the invoice sent. + await ctx.db.delete(invoiceShareTb).where(eq(invoiceShareTb.id, share.id)) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'E-mail se nepodařilo odeslat. Zkuste to prosím znovu.' + }) + } + const sentAt = djs().format('YYYY-MM-DD') + await ctx.db + .update(invoicesTb) + .set( + input.kind === 'invoice' + ? { sent_at: sentAt } + : { reminder_sent_at: sentAt } + ) + .where( + and(eq(invoicesTb.id, invoice.id), eq(invoicesTb.user_id, ctx.user.id)) + ) + return { sentAt } + }) diff --git a/faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts b/faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts new file mode 100644 index 0000000..5443e01 --- /dev/null +++ b/faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + invoiceEmailLanguage, + invoiceEmailTemplate +} from 'faktorio-shared/src/invoiceEmail' + +const data = { number: '2026-123', dueOn: '2026-09-21', senderName: 'Seller' } + +describe('invoice email templates', () => { + it('uses the contact language before the invoice language, with Czech fallback', () => { + expect(invoiceEmailLanguage('en', 'cs')).toBe('en') + expect(invoiceEmailLanguage('cs', 'en')).toBe('cs') + expect(invoiceEmailLanguage(null, 'en')).toBe('en') + expect(invoiceEmailLanguage('unsupported')).toBe('cs') + }) + it.each(['cs', 'en'] as const)( + 'provides invoice and reminder text in %s', + (language) => { + const invoice = invoiceEmailTemplate(data, language, 'invoice') + const reminder = invoiceEmailTemplate(data, language, 'reminder') + expect(invoice.subject).toContain(data.number) + expect(reminder.subject).toContain(data.number) + expect(invoice.body).toContain( + language === 'cs' ? '21. 9. 2026' : data.dueOn + ) + expect(reminder.body).toContain( + language === 'cs' ? 'neuhrazenou' : 'remains unpaid' + ) + expect(invoice.body).toContain(data.senderName) + expect(reminder.body).not.toBe(invoice.body) + } + ) +}) diff --git a/faktorio-api/src/routers/invoices/invoiceRouter.ts b/faktorio-api/src/routers/invoices/invoiceRouter.ts index c53a518..30a14c5 100644 --- a/faktorio-api/src/routers/invoices/invoiceRouter.ts +++ b/faktorio-api/src/routers/invoices/invoiceRouter.ts @@ -27,6 +27,7 @@ import { invoiceItemFormSchema } from '../../zodDbSchemas' import { getInvoiceSums } from './getInvoiceSums' import { getCNBExchangeRate } from './getCNBExchangeRate' import { invoiceShareEventTb, invoiceShareTb } from 'faktorio-db/schema' +import { getEmailDraft, sendInvoiceEmail } from './invoiceEmail' const invoiceSchema = getInvoiceCreateSchema(djs().format('YYYYMMDD') + '001') @@ -54,6 +55,8 @@ const updateInvoiceInput = z.object({ }) export const invoiceRouter = trpcContext.router({ + getEmailDraft, + sendEmail: sendInvoiceEmail, createShare: protectedProc .input( z.object({ diff --git a/faktorio-api/src/sendEmail.ts b/faktorio-api/src/sendEmail.ts index 6fcd54c..509dca5 100755 --- a/faktorio-api/src/sendEmail.ts +++ b/faktorio-api/src/sendEmail.ts @@ -1,11 +1,15 @@ import type { SendEmail } from '@cloudflare/workers-types' +export type EmailData = { + to: { email: string; name: string } + subject: string + html: string + text?: string + replyTo?: { email: string; name: string } +} + export async function sendEmail( - emailData: { - to: { email: string; name: string } - subject: string - html: string - }, + emailData: EmailData, env: { SEND_EMAIL?: SendEmail } ): Promise { if (!env.SEND_EMAIL) { @@ -16,7 +20,9 @@ export async function sendEmail( from: { email: 'no-reply@faktorio.cz', name: 'Faktorio' }, to: emailData.to, subject: emailData.subject, - html: emailData.html + html: emailData.html, + ...(emailData.text ? { text: emailData.text } : {}), + ...(emailData.replyTo ? { replyTo: emailData.replyTo } : {}) }) console.log('Email accepted by Cloudflare Email Service:', messageId) diff --git a/faktorio-api/src/trpcContext.ts b/faktorio-api/src/trpcContext.ts index 57bc142..d67f8b1 100644 --- a/faktorio-api/src/trpcContext.ts +++ b/faktorio-api/src/trpcContext.ts @@ -5,6 +5,7 @@ import superjson from 'superjson' import { UserSelectType } from 'faktorio-db/schema' import { Env } from './envSchema' +import type { EmailData } from './sendEmail' // JWT secret should be the same as in authRouter @@ -15,11 +16,7 @@ export type TrpcContext = { user: UserSelectType | undefined req: Request generateToken: (user: UserSelectType) => Promise - sendEmail: (email: { - to: { email: string; name: string } - subject: string - html: string - }) => Promise + sendEmail: (email: EmailData) => Promise } // @ts-ignore const isBrowser = typeof window !== 'undefined' diff --git a/faktorio-api/wrangler.toml b/faktorio-api/wrangler.toml index 1d4da76..dee482e 100644 --- a/faktorio-api/wrangler.toml +++ b/faktorio-api/wrangler.toml @@ -28,6 +28,7 @@ allowed_sender_addresses = ["no-reply@faktorio.cz"] # Note: Use secrets to store sensitive data. # Docs: https://developers.cloudflare.com/workers/platform/environment-variables [vars] +PUBLIC_APP_URL = "https://faktorio.cz" INVOICE_LOGO_PUBLIC_BASE_URL = "https://uploads.faktorio.cz" diff --git a/faktorio-fe/src/components/FeatureComparison.tsx b/faktorio-fe/src/components/FeatureComparison.tsx index de76bfa..04a0347 100644 --- a/faktorio-fe/src/components/FeatureComparison.tsx +++ b/faktorio-fe/src/components/FeatureComparison.tsx @@ -61,7 +61,7 @@ const features = [ }, { name: 'Odesílání faktur emailem z aplikace', - faktorio: false, + faktorio: true, fakturovac: true, fakturoid: true, idoklad: true, diff --git a/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx b/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx index a289c00..1489f6c 100644 --- a/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx +++ b/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx @@ -2,6 +2,7 @@ import { PDFDownloadLink } from '@react-pdf/renderer' import { CzechInvoicePDF } from './CzechInvoicePDF' import { InvoicePdfPreview } from './InvoicePdfPreview' +import { InvoiceEmailControls } from './InvoiceEmailControls' import { Button } from '@/components/ui/button' import { snakeCase } from 'lodash-es' import { useLocation, useParams, useSearchParams } from 'wouter' @@ -273,6 +274,12 @@ export const InvoiceDetail = ({ + {!isLocalUser && ( +
+ +
+ )} +
(null) + const utils = trpcClient.useUtils() + const draft = trpcClient.invoices.getEmailDraft.useQuery( + { invoiceId: invoice.id, kind: kind ?? 'invoice' }, + { enabled: kind !== null, staleTime: 0 } + ) + const send = trpcClient.invoices.sendEmail.useMutation({ + onSuccess: () => { + toast.success('E-mail byl odeslán.') + setKind(null) + void utils.invoices.invalidate() + }, + onError: (error) => toast.error(error.message) + }) + + return ( +
+

Odeslání e-mailem

+

+ Pošlete klientovi fakturu nebo připomenutí platby s odkazem na zobrazení + a stažení PDF. +

+
+ + +
+ {invoice.sent_at && ( +

+ Poslední odeslání faktury: {invoice.sent_at} +

+ )} + {invoice.reminder_sent_at && ( +

+ Poslední upomínka: {invoice.reminder_sent_at} +

+ )} + { + if (!open && !send.isPending) setKind(null) + }} + > + + + + {kind === 'reminder' ? 'Odeslat upomínku' : 'Odeslat fakturu'} + + + Text je předvyplněn podle jazyka kontaktu. Příjemce, předmět i + zprávu můžete upravit. + + + {draft.isError ? ( +
+

{draft.error.message}

+ +
+ ) : !draft.data ? ( +

Načítání…

+ ) : ( + kind && ( + setKind(null)} + onSend={(fields) => + send.mutate({ invoiceId: invoice.id, kind, ...fields }) + } + /> + ) + )} +
+
+
+ ) +} + +export function InvoiceEmailForm({ + draft, + kind, + pending, + onCancel, + onSend +}: { + draft: RouterOutputs['invoices']['getEmailDraft'] + kind: InvoiceEmailKind + pending: boolean + onCancel: () => void + onSend: (fields: EmailFields) => void +}) { + const [language, setLanguage] = useState(draft.language) + const [to, setTo] = useState(draft.to) + const [message, setMessage] = useState(() => + invoiceEmailTemplate(draft, draft.language, kind) + ) + const changeLanguage = (next: InvoiceEmailLanguage) => { + const previous = invoiceEmailTemplate(draft, language, kind) + if ( + (message.subject !== previous.subject || + message.body !== previous.body) && + !window.confirm( + 'Změna jazyka nahradí upravený předmět a text e-mailu novou šablonou. Pokračovat?' + ) + ) + return + setLanguage(next) + setMessage(invoiceEmailTemplate(draft, next, kind)) + } + const submit = (event: FormEvent) => { + event.preventDefault() + if (!pending) onSend({ to: to.trim(), language, ...message }) + } + return ( +
+
+
+ + setTo(e.target.value)} + /> +
+
+ + +

+ Změní pouze tento e-mail, nikoli jazyk kontaktu nebo faktury. +

+
+
+ + + setMessage({ ...message, subject: e.target.value }) + } + /> +
+
+ +