From 14e1f03b666083c7a2c3e70b02e31d77ca1cad07 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Thu, 1 Oct 2026 13:43:24 +0200 Subject: [PATCH] added email delivery for Account creation, Invoices, Credentials Creation --- .../20261001120000_invoice_email.sql | 6 + modules/admin/actions/client.ts | 17 +- .../admin/ui/components/add-client-dialog.tsx | 5 + modules/shared/actions/credentials.ts | 24 ++- modules/shared/actions/invoices.ts | 77 +++++++- modules/shared/data/company.ts | 17 ++ modules/shared/mail/app-url.ts | 15 ++ .../shared/mail/assets/since3-logo-dark.png | Bin 0 -> 6833 bytes .../shared/mail/assets/since3-logo-light.png | Bin 0 -> 19150 bytes modules/shared/mail/layout.ts | 175 ++++++++++++++++++ modules/shared/mail/send-mail.ts | 88 +++++++++ modules/shared/mail/templates.ts | 100 ++++++++++ modules/shared/pdf/invoice-document.tsx | 18 +- modules/shared/queries/invoices.ts | 22 ++- modules/shared/queries/mail-recipient.ts | 29 +++ modules/shared/schemas/invoice.ts | 2 + .../shared/ui/components/billing-dialog.tsx | 46 ++++- .../shared/ui/components/invoices-table.tsx | 81 +++++++- modules/shared/ui/views/billing-panel.tsx | 18 +- 19 files changed, 697 insertions(+), 43 deletions(-) create mode 100644 lib/supabase/migrations/20261001120000_invoice_email.sql create mode 100644 modules/shared/data/company.ts create mode 100644 modules/shared/mail/app-url.ts create mode 100644 modules/shared/mail/assets/since3-logo-dark.png create mode 100644 modules/shared/mail/assets/since3-logo-light.png create mode 100644 modules/shared/mail/layout.ts create mode 100644 modules/shared/mail/send-mail.ts create mode 100644 modules/shared/mail/templates.ts create mode 100644 modules/shared/queries/mail-recipient.ts diff --git a/lib/supabase/migrations/20261001120000_invoice_email.sql b/lib/supabase/migrations/20261001120000_invoice_email.sql new file mode 100644 index 0000000..591fb0c --- /dev/null +++ b/lib/supabase/migrations/20261001120000_invoice_email.sql @@ -0,0 +1,6 @@ +-- Wann und an wen eine Rechnung zuletzt per Mail verschickt wurde. +-- Versand passiert nur manuell durch den Admin; bestehende Rechnungen bleiben unverschickt (null). + +alter table public.invoices + add column if not exists email_sent_at timestamptz, + add column if not exists email_sent_to text; diff --git a/modules/admin/actions/client.ts b/modules/admin/actions/client.ts index 374f809..754a907 100644 --- a/modules/admin/actions/client.ts +++ b/modules/admin/actions/client.ts @@ -2,6 +2,9 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { verifySession } from "@/lib/supabase/dal"; import { generatePassword } from "@/lib/generate-password"; +import { getAppUrl } from "@/modules/shared/mail/app-url"; +import { sendMail } from "@/modules/shared/mail/send-mail"; +import { welcomeMail } from "@/modules/shared/mail/templates"; import * as z from "zod"; @@ -13,7 +16,7 @@ const CreateClientSchema = z.object({ export type CreateClientState = | { error: string } - | { success: true; email: string; password: string } + | { success: true; email: string; password: string; welcomeMailSent: boolean } | undefined; export async function createClient( @@ -41,7 +44,7 @@ export async function createClient( const password = generatePassword(); const supabase = createAdminClient(); - const { error } = await supabase.auth.admin.createUser({ + const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true, @@ -52,5 +55,13 @@ export async function createClient( return { error: error.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht angelegt werden." } } - return { success: true, email, password } + const mail = welcomeMail({ name, email, appUrl: await getAppUrl() }); + const sent = await sendMail({ + to: email, + ...mail, + type: "welcome", + idempotencyKey: `welcome-${data.user.id}`, + }); + + return { success: true, email, password, welcomeMailSent: sent.ok } } \ No newline at end of file diff --git a/modules/admin/ui/components/add-client-dialog.tsx b/modules/admin/ui/components/add-client-dialog.tsx index e9ce5df..c53c72d 100644 --- a/modules/admin/ui/components/add-client-dialog.tsx +++ b/modules/admin/ui/components/add-client-dialog.tsx @@ -25,6 +25,11 @@ function AddClientDialogContent({ onDone }: { onDone: () => void }) { + {state.welcomeMailSent ? ( +

Willkommensmail an {state.email} wurde gesendet.

+ ) : ( +

Die Willkommensmail konnte nicht gesendet werden.

+ )}
diff --git a/modules/shared/actions/credentials.ts b/modules/shared/actions/credentials.ts index 986cc4e..c34be66 100644 --- a/modules/shared/actions/credentials.ts +++ b/modules/shared/actions/credentials.ts @@ -2,10 +2,16 @@ import * as z from "zod"; import { revalidatePath } from "next/cache"; +import { after } from "next/server"; import { createAdminClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; import { verifySession } from "@/lib/supabase/dal"; import { getClientId } from "@/modules/shared/queries/profile"; +import { getMailRecipient } from "@/modules/shared/queries/mail-recipient"; +import { getServiceById } from "@/modules/shared/data/services-data"; +import { getAppUrl } from "@/modules/shared/mail/app-url"; +import { sendMail } from "@/modules/shared/mail/send-mail"; +import { credentialAddedMail } from "@/modules/shared/mail/templates"; const CredentialSchema = z.object({ profileId: z.string().uuid(), @@ -46,7 +52,7 @@ export async function createCredential( } const supabase = await createClient(); - const { error } = await supabase.rpc("create_credential", { + const { data: credentialId, error } = await supabase.rpc("create_credential", { p_client_id: clientId, p_service: validatedFields.data.service, p_username: validatedFields.data.username, @@ -55,6 +61,22 @@ export async function createCredential( if (error) return { error: "Eintrag konnte nicht gespeichert werden." }; + // Nur wenn wir den Zugang hinterlegen – legt der Kunde ihn selbst an, braucht er keine Mail. + if (session.isAdmin) { + const appUrl = await getAppUrl(); + const serviceLabel = getServiceById(validatedFields.data.service).label; + after(async () => { + const recipient = await getMailRecipient(profileId); + if (!recipient) return; + await sendMail({ + to: recipient.email, + ...credentialAddedMail({ name: recipient.name, serviceLabel, appUrl }), + type: "credential", + idempotencyKey: `credential-${credentialId}`, + }); + }); + } + revalidatePath("/client/passwords"); revalidatePath(`/admin/clients/${profileId}/passwords`); diff --git a/modules/shared/actions/invoices.ts b/modules/shared/actions/invoices.ts index fcadb23..f5b2442 100644 --- a/modules/shared/actions/invoices.ts +++ b/modules/shared/actions/invoices.ts @@ -5,6 +5,12 @@ import { revalidatePath } from "next/cache"; import { createClient } from "@/lib/supabase/server"; import { verifySession } from "@/lib/supabase/dal"; import { getClientId } from "@/modules/shared/queries/profile"; +import { getInvoicesForPdf } from "@/modules/shared/queries/invoices"; +import { getMailRecipient } from "@/modules/shared/queries/mail-recipient"; +import { getAppUrl } from "@/modules/shared/mail/app-url"; +import { sendMail } from "@/modules/shared/mail/send-mail"; +import { invoiceMail } from "@/modules/shared/mail/templates"; +import { invoiceFileName, renderInvoicePdf } from "@/modules/shared/pdf/render-invoice"; import { INVOICE_STATUSES, InvoiceDraftSchema, @@ -12,16 +18,60 @@ import { type InvoiceDraft, type InvoiceStatus, } from "@/modules/shared/schemas/invoice"; -import { toIsoDate } from "@/modules/shared/lib/invoice-math"; +import { invoiceTotals, toIsoDate } from "@/modules/shared/lib/invoice-math"; export type InvoiceActionState = { error: string } | { success: true }; +export type CreateInvoiceState = { error: string } | { success: true; mailError?: string }; function revalidateBilling(profileId: string) { revalidatePath("/client/billing"); revalidatePath(`/admin/clients/${profileId}/billing`); } -export async function createInvoice(draft: InvoiceDraft): Promise { +// Versendet eine Rechnung als PDF an den Ansprechpartner. Aufrufer muss Admin-Rechte bereits geprüft haben. +async function deliverInvoiceMail(profileId: string, clientId: string, invoiceId: string): Promise<{ error: string } | null> { + const supabase = await createClient(); + const { data: invoice } = await supabase + .from("invoices") + .select("id, status") + .eq("id", invoiceId) + .eq("client_id", clientId) + .maybeSingle(); + + if (!invoice) return { error: "Rechnung nicht gefunden." }; + if (invoice.status === "cancelled") return { error: "Stornierte Rechnungen werden nicht verschickt." }; + + const [pdfData] = await getInvoicesForPdf([invoiceId]); + const recipient = await getMailRecipient(profileId); + if (!pdfData || !recipient) return { error: "Rechnung oder Empfänger konnte nicht geladen werden." }; + + const pdf = await renderInvoicePdf(pdfData); + const mail = invoiceMail({ + name: recipient.name, + invoiceNumber: pdfData.invoiceNumber, + invoiceDate: pdfData.invoiceDate, + dueDate: pdfData.dueDate, + grossTotal: invoiceTotals(pdfData.items).gross, + appUrl: await getAppUrl(), + }); + + const sent = await sendMail({ + to: recipient.email, + ...mail, + type: "invoice", + attachments: [{ filename: invoiceFileName(pdfData.invoiceNumber), content: new Uint8Array(pdf), contentType: "application/pdf" }], + }); + if (!sent.ok) return { error: sent.error }; + + await supabase + .from("invoices") + .update({ email_sent_at: new Date().toISOString(), email_sent_to: recipient.email }) + .eq("id", invoiceId); + + return null; +} + +export async function createInvoice(draft: InvoiceDraft, options: { sendEmail?: boolean } = {}): Promise { const session = await verifySession(); if (!session.isAdmin) return { error: "Keine Berechtigung." }; @@ -33,7 +83,7 @@ export async function createInvoice(draft: InvoiceDraft): Promise { + const session = await verifySession(); + if (!session.isAdmin) return { error: "Keine Berechtigung." }; + + const validated = SendSchema.safeParse({ profileId, invoiceId }); + if (!validated.success) return { error: "Ungültige Rechnung." }; + + const clientId = await getClientId(validated.data.profileId); + if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." }; + + const failure = await deliverInvoiceMail(validated.data.profileId, clientId, validated.data.invoiceId); + if (failure) return failure; + + revalidateBilling(validated.data.profileId); return { success: true }; } diff --git a/modules/shared/data/company.ts b/modules/shared/data/company.ts new file mode 100644 index 0000000..819945e --- /dev/null +++ b/modules/shared/data/company.ts @@ -0,0 +1,17 @@ +// Firmendaten für Rechnungen (PDF) und E-Mails. +export const COMPANY = { + name: "Since3 GbR", + street: "Sandweg 93", + city: "48565 Steinfurt", + phone: "+49 176 47601638", + email: "info@since3.de", + web: "www.since3.de", + taxNumber: "311/5963/2094", + accountHolder: "Since3", + iban: "DE35 4007 0211 0068 9224 00", + bic: "DEUTDE3BP10", + bank: "Postbank", + partners: "Ben Luis Dalhoff, Rajpreet Singh", + // Kleinunternehmerregelung nach § 19 UStG. Bei Wechsel zur Regelbesteuerung auf false setzen. + smallBusiness: true, +} diff --git a/modules/shared/mail/app-url.ts b/modules/shared/mail/app-url.ts new file mode 100644 index 0000000..0a21b3e --- /dev/null +++ b/modules/shared/mail/app-url.ts @@ -0,0 +1,15 @@ +import "server-only" +import { headers } from "next/headers" + +// Basis-URL für Links in Mails. APP_URL hat Vorrang, auf Vercel greift die Produktionsdomain, sonst der aktuelle Request. +export async function getAppUrl() { + if (process.env.APP_URL) return process.env.APP_URL.replace(/\/$/, "") + if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { + return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + } + + const headerList = await headers() + const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000" + const protocol = headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https") + return `${protocol}://${host}` +} diff --git a/modules/shared/mail/assets/since3-logo-dark.png b/modules/shared/mail/assets/since3-logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d96bf1a6c6c0e163d3aece61c341c87b6bc0819e GIT binary patch literal 6833 zcmY+JcQD)!(EmTDC%Wh*;arI5EqZt2M6X{&jS@uf1Q#uOxFdQCA-d?@i6|#ZI6WNV zl;}o8KSLKRfT)-PxU;dCmUwj@Q?HN_m&{E&u?OFimws003$J)5>Hd|6-8H z!43eh2Eo)-jDr^Utz4R(n`GPs%!CR@(+r~?sb*m)xCD)8;>6HJpQ`$bK7CIhdBZaw z<;Ok$DDFd1b;}P?aGV-<2f);5M?$Dh=aef-lOaM(1k=Q0t19avmb!!G-iEGJRff80 z`kYw=E{D`r>h6b*E3Pa!>ygIM^BI9AcEs-zG4@1+QkF**k<7jUh|6lLRcOhd&%6q)#GgUUX=e&p#Si(xk z(|_})7VDd_2prrsu~|8)dME7s6I8?XSarF?Os4)#;>`rs#%)wV!9b!TG6a+kkO3rz z*+pjq9tp-GkM6?7;FWRH!1R3i+CEqdjm*gP4_jiHA^a6 ze%cFun8BN5dcJpi847J_OC%^P5I|b50M`MkcX5Xd069ni@M?{d-)Pn&p4UuEU^4E0 zz$EP?A13Ni$IZ}|!5G%9h|Y$1a<@mG8Y!VIVd10A436T4yV0hH;xNT^B5zSN=Gqwy zeQYbrh~P!OeggC)EPf%SC%Rl-i3%}^JY3T8IV3}*EfW8`UYDz#AN{${RNk=EY2%wj zHwO$$3%7qrc$Gy^#IU?(ytG9MyFs?}YFtSE{JPt~pePYiel2WR%EM?w@J9q*NH7m5 z6URrzCJ@FFe*;~ypc4OfFrfy47LbUkl9BI9h5c|}P)^)y{*hNI{5i1{>Z#$Oz3E*w zj|@u*0(gw_d8D@`R7==2@2mMFO0;vgXZJ*eCe(CoE(!=#*twJQh2#oC{xPr*f+4yg z6Gk*_UaQiDI2D!^zH$&fS`Yc05$<4Oqon8a99tU8N!8f(gfg8a;VCi?>eHRYMaO5j z8^{yVJM02yLhO-)MAf32Y`t@PFVkLQKqYh9f;%60dFvWAJr4fx*Pscf*R3C@;A!_q z8&CY4{(-kc$kDpo6N%eWIFg)y|7M4;&vY4uk9Qh#rh+S#xhv}78~a);XG`Dkbu!q| zp=~-CM!(z%z@H%^B~1$zO~l-17XF$B_YN%j7Cf_12;RBw<~$YHGb=>!a3I>(2D|Bx zggAi8kiWIl2`jq4c+tlcVGt%_zR&N4;tdC=*&C~Erb+u$vt@?;4%Y7`tt;0 z0sKf98hB$n_tK zmrUjtTZ#zzyU#q&y>@-dv8%K);=SvVCX$ugtL0+@FabfWJyuXUYXDMALSP#Kyp120 z3|1vt3grKLeg>Qo)$=1nVv>O*6}tB#fGr<96ObodwFAx*hr#F9VsH!Wv?6hwV9uT= zd4G_ay0r_mk_&wYnC-6&UVWzS;ApW@seb^nm4yd_g38a_h*35mdCFC5Kq_&VZa2#G zqh?vsrLN}GW*NaeD~bpxNsTTSfYN=z6Cs+~FTi*oEz6o!Y6vgEyxB!=5+vG*W8+#9 zm>_6-fU*MPOEJ~|so%Cg`HyIxAV>mg4YIW_NqiG$3_BoOZ7Zs2_^I&#jsda5@QGlb zk8kSZj7>=?OK{UTO4yB_Evkl?pAdEf#q15@%qjC)WMgq#nm5=C@~X`D$Sxyy{@hKI zt$df_Oq|go>9r7lae-S5HbTFtX4VC?!rgADfc*CRlwzn;VMqgVn~ziTKim2SnRwW) zyU19#YBj=);6-$mr@)={>MC^()D+!o$*j9i(r_zjS3MpayEq7A40|StnD2A^#e|km$8~5N+9;ecN010-barJ(TW{MPh#ecd( z{a18vq8-9Z&r(AJpD@^Awx!vXnlhp65r2mxO)z9+FLbULl1fLf3F&1bm)dfbQRwkI zES7uJ8zw^2iPu^QuS}N@yL9mqLwfC1qc3_0H4jwr9qDl6GVQFeMCI^ul{}`aTK{cZ zjv`}UWCz_*w|ZZ(9i0>byLZC3MUy-thQL5qOY%(M%A}G@iZp93*aT6Jmm?hA<-mR{1&L` zp^A55@}?1D_k~$Q@veyT9oGVl*>{B_mlWgo6cTWfeCyWFST5&GF2l^3J_+d8$yy7) ziZ$CJ%2xJy6MEAGvoh)_&`N{a$S(wih*BTXE8L!j2Wl0ZMS=s!`)YrCcSflD zAiv}0XiqI{BH*|Zf01xLw4mw_1JG()#K?%Dzo@YdkBp8Q>4*wjiA0R6ELwc&GimXe zxcF8;u{u5oajS|i6t9h#xs;NlNSzG~+*bVp7$1OT)bc6qLUUb}Od|pNT)ub zml>d-=)|82l^2I)EtpeHkD_8H(np29;(tT9(8;;xG*+L+_dyZ&kM0{{19)Uid&RYy zPr3b`aVR2>&+2*1{a*3^!a3C`gT(IZJ<>9b5`Dmt#jgsj-mJ9cc;MRUzMxAgn%w(I z?KCVER}8J{@gVLLuXi`KVksMo#q5b;L+f&1LxP{lwMyoY|A4_qr*5pzL7gDZ=!bvx z#=pPU^r>^Y#OS2gmBLG|XUQS&T#CkAR9<<+;KIj_w*Pc;60*BN^mrF6pIRPb;tw1b z7dwlLEZup!FKW6$$GbZ&~65i~EBP{DbrksW3^O!#~e|eYsvhqZR znkzUWVJxSty_IhpEB8f+;;)=L?`5>0e#~!6)K_)HZ9eVLc6JZ~_tc{MJP_$+ItY>f z;U*qDdS}1k#E(Upb-PR5=Z8l4T=PX5MNwPJBn$`NMbT%ATT{ObFc_7q;E1>+NlZKr z4n-zLo^CL_VW}!R&Ry3KW05=-=yqjmb_+y2n3g$xOOHt$=nSu4hI2gf#7r+Sj_2wJ zUpAxtFzW|q`iR0$+-08&aZ;MKD^!OD9Kgl8Jo00|b)N@FvqR8M@1CuwV7)?4_0rO( z_l(j`u6*lZP}(qLf3U;VwfySD-{V!DMAdolbpgeyLZz~EoD^*mJ>8aCPq?m671g48 zzxz4t?!3!pZ)Ib7L?1e$!j5ZlX1zm}qS$xQo~*_*(aN{@-fky@0a*FTM(INFgV!+N1SnE-ywN$sCPQx9HvbU9b0zfJ?2b&^1rvt>#8N=p79-?o6G310 zjEqYtBzP?rVH1$CV*pl2>vQ9t+q1^T887--JlUMDNDS6!LZaLaBanU8&9@seO+{th#x->-8QU)@h}0)2YqJ$)HIVma@8PCtFa z&3F65A_HKoKeFsSD1kSmV6PodBIps~OXY;pgsAHr=bqy6u@uL@ahd}G_rPMLFoRTa zkbtik_^&)q;o+F8@H0u9S`O2Id^6%BQouQGn4=~CV0BIYL)7s@z;_8pON@RDcL!Y& zp)z4Nj3^UeGk!S1mhFW}=+CvS0fS~3T*r+RxVc|-U&ztVro4tbq>%N<*>}rjx#vB) z?DyOI*H~Y^!(_txJMg*X1HME1KMFwaoz1Bb%}Rs)YKvW>>%F3ICk}uYB%O|%1UD2R z=Zg|7OoF#bkNgp4AZa~@a>esk#z(JSDn;mOdF(0({kAohdTBFWfAx{x(u_IN~wtu>?4Gh0_D|BI9z2ez3(im`a2sE#)|SmkOR+2S6yj?N25ID zI~vvNVi>_k-MYS1}z`!K|90@_x(saoR`>16uI?TP+zv8WmmE$ zKUfsK0<@8#CM!42E3G`);rz{i&!cDL3>l@2+k+17=#uwu^ZeR|pYKI}D9FHwVn15UPIo{uB60S2JH}2na zo(?tM#fcK@BD?g|sf2qm7~C z*UVGlnB?S$=nnZfsjMETQ)>$PR7`kTl(w~kI8A$ z!6k52z6h(*HLX^?&;raP={LE-QUvpqsE;1G7Ty}$cj05ebB8-?n*#^*Mf1v^v?9to zE&>UTyn$Qt)d^<2^}>@6o7$AKE+zm5SW?ATlOe2#)2p~o8T?K=+W+&3Fq4&D2~o$H z19SF|k_IeEK)wxrPq~qS2*{ zJHFxv8%&;QsnRfDf(&p*vU7uPY26X|p9{9Tl?We&(#ylIte51kvSX!Wq5ap1Kbq-b zjMNu~q>?Xxcw>uNhU-1CQ4mO~;LSC{C{>r(i;+&R(+uva+aCr(WrKZSwpFj@XIFRY z^Y_n=&D-bSA+Nk%uJ07N8#d@xV`Ni@NS=Jt(vZw$sc9)!XT#NpHTdhxj=nSCntAWD z$T>H&SYo$4WY(0T^3KWcls?QqD4a!cFQ3*DCVyo0Z&<3 zdaB-wpJtF&m|F=s3h(Yr<(Bb5&M2Wa=Y?~c5Z%<5z7!yP*)(f#Yv>o;BULmoHJke6 zmlW?5YXz1Xy@xBp6(x^hsMHe$K1~jiFSeK(Wfz^;1XA z{hOzsX3}HhlKj`Ny>)U@2{m{7@&vQh9eOAL>Y-wTAN=X73*Y%Ye`6dG9HV zYnzhPtF|s-)NwqXRRTnu9BszWE!Ol>)u(NeTIIYcYzIMYG^pnYOjwRhtG41sg;Rbl z>Q*dP-iPPNxYKcn=ck4CL#wPCW}sxUNjDLJeek8SBDG~$mxWHoZqh6>X_C1*XVAGx z)xGi2>Z^-}{$f5z9qExcghlawXxF_>5{PbAU4mvWpIh>gp(~BCE0w-AP1*z}j4Xm| z;m+DWjw_snJ-3r8tV-ft%FpA5#UKTO-UJ>6m;16V6gIgGuU)Vy z91#ey@>g4*c}9|5J|iQceFdijUBm(cWd`0atPsxJuwX^@D#nQl8A=~fxL*A5B@{O) zALFMb13c6wyKSgtcAPT^Cl41x%%j&kxB`5bbLU>LczV)+8n_);*T*_m-xHn;nd){5 z`=|B>1Ml0G-yUBmBJKgx9V)ZrLR0qUVwc3e{fyC1@k!4#wAam<6I=v31$*`#oVoc* zeO+k(cU4HNkaFn9xV$I`$1w@T01NB-SqM3hiRdc8rGxExmu0x2cWfM^<7q0o7sGU7 zu4)>b_;N;={0VRhPEBk1$^5W068J7h1kcGjkXLk-LB1+H5uf_d*>vPvB1Gzp;EyJp zw`I4Zg5@JP&LvMmTf5}cml{qAunT|-@1j%bmpf#_ z#pR>B8fTDSjG+IPIob)m)- zF46x@g?qn!YhDLbnImR1{?gcb+2!4((^rXDu@FIYeu94LqQ{n39rg6O#M8U0@=e&c ze*9;_2baCP7g&0e<;U^m94~&wc&d%{=_bZF6wv98Q-~}%4#5v{K&g$V*F9;A78pQ` z`GlInmtdU^@wQaE&5p3zUsUO7)a0RuPg=7`LB&B_q&`>M8{AhQtLydq?$Ho;oe9>6 z<Uq%}S}u zdBw|dd*B_n+}3@ukK+nT}?;2656UAIvx_;RjiDSn#-P{-LP!k*y5n+)r=6taFhrAE#o-zXwq{ zUT$MUF07U%hKsIGVc4Hdjjpj{t`Js=ooLpu8kpd)LzCIN3P=6!jz!K02HwJg-o~A2 za80j<+g-0kUo+P9&k2o~&;qB8X5^2S=hq<5mpY4>{AJ%Ug&<+CpIuS7G#TD+eUL3u zWhrK|XUR{6OyrK^U=PvVj06ghmw(CC6 zP2%B)wSu9wx%eFZJc?5n0@eT6+Zox-aso`m2E*(?KEE&^;o;Rf)}ypR--jRDPArM- zSiM~T`W`s9$M6e0Qeb939D&cN%sEG?6mpoR-f~##oItZ!&uHTM+w6P*&F zp!;?!t(45Vc)h3k<>mX`7b=FzXY;5QX?6wDRb5Cq1@;k`{{+u8nw{J{p%@Ke^FYex z1l@z_pP2yR55W_yBRumJ9Sv5X)EXj-nVS}<57}RcJU76;!Xez6f9Nm*$ZOeuzjyJzz^km%5zcxj3A3U*+Y zDr=bc0z7D0Hf%1lzYD Y@o78D5-XV5KPw7=L3Pz@Rc#~x2X;IY(EtDd literal 0 HcmV?d00001 diff --git a/modules/shared/mail/assets/since3-logo-light.png b/modules/shared/mail/assets/since3-logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..67ce103e1b94bab01f552720b6b313576d2bfa49 GIT binary patch literal 19150 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

APS?-&~|UX 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/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export type MailLayout = { + preheader: string + heading: string + // Bereits escaptes HTML (mit escapeHtml gebaut). + paragraphs: string[] + details?: { title?: string; rows: [label: string, value: string][] } + cta?: { label: string; href: string } + closing?: string[] + appUrl: string +} + +function paragraph(html: string) { + return `

${html}

` +} + +function detailsPanel({ title, rows }: NonNullable) { + const rowsHtml = rows + .map( + ([label, value]) => ` + + ${escapeHtml(label)} + ${escapeHtml(value)} + ` + ) + .join("") + + return ` + + + + + ` +} + +function button({ label, href }: NonNullable) { + return ` + + + + +
+ ${escapeHtml(label)} +
` +} + +export function renderMailLayout(mail: MailLayout) { + const appUrl = mail.appUrl.replace(/\/$/, "") + const closing = mail.closing ?? ["Viele Grüße", "Ihr Since3 Team"] + + return ` + + + + + + + ${escapeHtml(mail.heading)} + + + +
${escapeHtml(mail.preheader)}​ ​ ​ ​ ​ 
+ + + + + + +` +} diff --git a/modules/shared/mail/send-mail.ts b/modules/shared/mail/send-mail.ts new file mode 100644 index 0000000..c57447d --- /dev/null +++ b/modules/shared/mail/send-mail.ts @@ -0,0 +1,88 @@ +import "server-only" +import { readFileSync } from "node:fs" +import path from "node:path" +import { COMPANY } from "@/modules/shared/data/company" +import { LOGO_CIDS } from "./layout" + +const RESEND_ENDPOINT = "https://api.resend.com/emails" +const DEFAULT_FROM = "Since3 " + +const ASSET_DIR = path.join(process.cwd(), "modules/shared/mail/assets") + +let logoAttachments: { filename: string; content: string; content_type: string; content_id: string }[] | null = null + +function logos() { + logoAttachments ??= (["light", "dark"] as const).map((variant) => ({ + filename: `since3-logo-${variant}.png`, + content: readFileSync(path.join(ASSET_DIR, `since3-logo-${variant}.png`)).toString("base64"), + content_type: "image/png", + content_id: LOGO_CIDS[variant], + })) + return logoAttachments +} + +export type MailAttachment = { filename: string; content: Uint8Array; contentType: string } + +export type SendMailResult = { ok: true; id: string } | { ok: false; error: string } + +// Wirft nie: Mailversand ist Nebeneffekt, der eigentliche Vorgang (Client, Rechnung, ...) soll davon nicht scheitern. +export async function sendMail({ + to, + subject, + html, + type, + attachments = [], + idempotencyKey, +}: { + to: string + subject: string + html: string + type: "welcome" | "credential" | "invoice" + attachments?: MailAttachment[] + idempotencyKey?: string +}): Promise { + const apiKey = process.env.RESEND_SECRET_API_KEY + if (!apiKey) { + console.error("[mail] RESEND_SECRET_API_KEY ist nicht gesetzt.") + return { ok: false, error: "Mailversand ist nicht eingerichtet." } + } + + try { + const response = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + }, + body: JSON.stringify({ + from: process.env.MAIL_FROM ?? DEFAULT_FROM, + to: [to], + reply_to: COMPANY.email, + subject, + html, + tags: [{ name: "type", value: type }], + attachments: [ + ...logos(), + ...attachments.map((attachment) => ({ + filename: attachment.filename, + content: Buffer.from(attachment.content).toString("base64"), + content_type: attachment.contentType, + })), + ], + }), + signal: AbortSignal.timeout(15_000), + }) + + const body = await response.json().catch(() => null) + if (!response.ok || typeof body?.id !== "string") { + console.error(`[mail] Resend hat ${type}-Mail abgelehnt (${response.status}):`, body?.message ?? body) + return { ok: false, error: "E-Mail konnte nicht gesendet werden." } + } + + return { ok: true, id: body.id } + } catch (error) { + console.error(`[mail] ${type}-Mail fehlgeschlagen:`, error) + return { ok: false, error: "E-Mail konnte nicht gesendet werden." } + } +} diff --git a/modules/shared/mail/templates.ts b/modules/shared/mail/templates.ts new file mode 100644 index 0000000..05247da --- /dev/null +++ b/modules/shared/mail/templates.ts @@ -0,0 +1,100 @@ +import { COMPANY } from "@/modules/shared/data/company" +import { formatCurrency, formatDate } from "@/modules/shared/lib/invoice-math" +import { escapeHtml, renderMailLayout } from "./layout" + +export type MailContent = { subject: string; html: string } + +function greeting(name: string | null) { + return name ? `Hallo ${escapeHtml(name)},` : "Hallo," +} + +export function welcomeMail({ name, email, appUrl }: { name: string; email: string; appUrl: string }): MailContent { + return { + subject: "Willkommen im Since3 Portal", + html: renderMailLayout({ + appUrl, + preheader: "Ihr Zugang zum Since3 Kundenportal ist eingerichtet.", + heading: "Willkommen im Since3 Portal", + paragraphs: [ + greeting(name), + "für Sie wurde ein Zugang zum Since3 Kundenportal eingerichtet. Dort finden Sie den Status Ihrer Systeme, Rechnungen, Dokumente und hinterlegte Zugangsdaten an einem Ort.", + `Bitte melden Sie sich einmal an und bestätigen Sie unsere Nutzungsbedingungen. Erst danach ist das Portal für Sie freigeschaltet.`, + ], + details: { + rows: [ + ["Login-E-Mail", email], + ["Passwort", "erhalten Sie separat von uns"], + ], + }, + cta: { label: "Zum Portal", href: appUrl }, + }), + } +} + +export function credentialAddedMail({ + name, + serviceLabel, + appUrl, +}: { + name: string | null + serviceLabel: string + appUrl: string +}): MailContent { + return { + subject: `Neuer Zugang hinterlegt: ${serviceLabel}`, + html: renderMailLayout({ + appUrl, + preheader: `Für ${serviceLabel} wurde ein Zugang im Since3 Portal hinterlegt.`, + heading: "Neuer Zugang hinterlegt", + paragraphs: [ + greeting(name), + `wir haben im Since3 Portal einen Zugang für ${escapeHtml(serviceLabel)} für Sie hinterlegt.`, + "Aus Sicherheitsgründen versenden wir Zugangsdaten nie per E-Mail. Sie finden Benutzername und Passwort jederzeit verschlüsselt im Portal unter „Passwörter“.", + ], + details: { rows: [["Dienst", serviceLabel]] }, + cta: { label: "Zugang ansehen", href: `${appUrl}/client/passwords` }, + }), + } +} + +export function invoiceMail({ + name, + invoiceNumber, + invoiceDate, + dueDate, + grossTotal, + appUrl, +}: { + name: string | null + invoiceNumber: string + invoiceDate: string + dueDate: string + grossTotal: number + appUrl: string +}): MailContent { + return { + subject: `Rechnung ${invoiceNumber} von Since3`, + html: renderMailLayout({ + appUrl, + preheader: `Rechnung ${invoiceNumber} über ${formatCurrency(grossTotal)}, fällig am ${formatDate(dueDate)}.`, + heading: `Rechnung ${invoiceNumber}`, + paragraphs: [ + greeting(name), + `anbei erhalten Sie unsere Rechnung ${escapeHtml(invoiceNumber)} vom ${formatDate(invoiceDate)} als PDF.`, + `Bitte überweisen Sie den Betrag bis zum ${formatDate(dueDate)} und geben Sie die Rechnungsnummer als Verwendungszweck an.`, + ], + details: { + title: "Zahlungsinformationen", + rows: [ + ["Betrag", formatCurrency(grossTotal)], + ["Fällig am", formatDate(dueDate)], + ["Kontoinhaber", COMPANY.accountHolder], + ["IBAN", COMPANY.iban], + ["BIC", COMPANY.bic], + ["Verwendungszweck", invoiceNumber], + ], + }, + cta: { label: "Im Portal ansehen", href: `${appUrl}/client/billing` }, + }), + } +} diff --git a/modules/shared/pdf/invoice-document.tsx b/modules/shared/pdf/invoice-document.tsx index 43a5959..5a1f414 100644 --- a/modules/shared/pdf/invoice-document.tsx +++ b/modules/shared/pdf/invoice-document.tsx @@ -1,23 +1,7 @@ import { Document, Image as PdfImage, Link, Page, StyleSheet, Text, View } from "@react-pdf/renderer" import type { InvoicePdfData } from "@/modules/shared/schemas/invoice" import { formatAmount, formatCurrency, formatDate, invoiceTotals, lineNet } from "@/modules/shared/lib/invoice-math" - -const COMPANY = { - name: "Since3 GbR", - street: "Sandweg 93", - city: "48565 Steinfurt", - phone: "+49 176 47601638", - email: "info@since3.de", - web: "www.since3.de", - taxNumber: "311/5963/2094", - accountHolder: "Since3", - iban: "DE35 4007 0211 0068 9224 00", - bic: "DEUTDE3BP10", - bank: "Postbank", - partners: "Ben Luis Dalhoff, Rajpreet Singh", - // Kleinunternehmerregelung nach § 19 UStG. Bei Wechsel zur Regelbesteuerung auf false setzen. - smallBusiness: true, -} +import { COMPANY } from "@/modules/shared/data/company" const INK = "#1a1a1a" const MUTED = "#6e6e6e" diff --git a/modules/shared/queries/invoices.ts b/modules/shared/queries/invoices.ts index de90037..69dd977 100644 --- a/modules/shared/queries/invoices.ts +++ b/modules/shared/queries/invoices.ts @@ -4,14 +4,20 @@ import type { InvoiceListItem, InvoicePdfData, ProductTemplate } from "@/modules export async function getInvoices(clientId: string): Promise { const supabase = await createClient() - const { data, error } = await supabase - .from("invoices_overview") - .select("id, invoice_number, status, display_status, gross_total, due_date, invoice_date") - .eq("client_id", clientId) - .order("invoice_date", { ascending: false }) - .order("invoice_number", { ascending: false }) + // Versandinfo direkt aus invoices: die View expandiert i.* nur zum Anlegezeitpunkt und kennt die Spalten nicht. + const [{ data, error }, { data: mailRows, error: mailError }] = await Promise.all([ + supabase + .from("invoices_overview") + .select("id, invoice_number, status, display_status, gross_total, due_date, invoice_date") + .eq("client_id", clientId) + .order("invoice_date", { ascending: false }) + .order("invoice_number", { ascending: false }), + supabase.from("invoices").select("id, email_sent_at, email_sent_to").eq("client_id", clientId), + ]) - if (error) throw new Error("Rechnungen konnten nicht geladen werden.") + if (error || mailError) throw new Error("Rechnungen konnten nicht geladen werden.") + + const mailInfo = new Map(mailRows.map((row) => [row.id, row])) return data.map((row) => ({ id: row.id, @@ -21,6 +27,8 @@ export async function getInvoices(clientId: string): Promise grossTotal: Number(row.gross_total), dueDate: row.due_date, invoiceDate: row.invoice_date, + emailSentAt: mailInfo.get(row.id)?.email_sent_at ?? null, + emailSentTo: mailInfo.get(row.id)?.email_sent_to ?? null, })) } diff --git a/modules/shared/queries/mail-recipient.ts b/modules/shared/queries/mail-recipient.ts new file mode 100644 index 0000000..888a998 --- /dev/null +++ b/modules/shared/queries/mail-recipient.ts @@ -0,0 +1,29 @@ +import "server-only" +import { createAdminClient } from "@/lib/supabase/admin" + +export type MailRecipient = { email: string; name: string | null } + +// Mails gehen an den Ansprechpartner aus den Stammdaten; ohne Stammdaten an die Login-Email. +// Nur nach erfolgter Berechtigungsprüfung aufrufen (Service-Role umgeht RLS). +export async function getMailRecipient(profileId: string): Promise { + const supabase = createAdminClient() + const { data: profile } = await supabase + .from("profiles") + .select("email, name, client_id") + .eq("id", profileId) + .maybeSingle() + + if (!profile) return null + + if (profile.client_id) { + const { data: client } = await supabase + .from("clients") + .select("contact_name, contact_email") + .eq("id", profile.client_id) + .maybeSingle() + + if (client?.contact_email) return { email: client.contact_email, name: client.contact_name ?? null } + } + + return { email: profile.email, name: profile.name ?? null } +} diff --git a/modules/shared/schemas/invoice.ts b/modules/shared/schemas/invoice.ts index 08e2b16..8537334 100644 --- a/modules/shared/schemas/invoice.ts +++ b/modules/shared/schemas/invoice.ts @@ -63,6 +63,8 @@ export type InvoiceListItem = { grossTotal: number; dueDate: string; invoiceDate: string; + emailSentAt: string | null; + emailSentTo: string | null; }; export type ProductTemplate = { diff --git a/modules/shared/ui/components/billing-dialog.tsx b/modules/shared/ui/components/billing-dialog.tsx index 9f04ad4..f7e970a 100644 --- a/modules/shared/ui/components/billing-dialog.tsx +++ b/modules/shared/ui/components/billing-dialog.tsx @@ -61,12 +61,14 @@ function BillingDialogContent({ prefill, nextInvoiceNumber, templates, + recipientEmail, onDone, }: { profileId: string; prefill: BillingPrefill; nextInvoiceNumber: string | null; templates: ProductTemplate[]; + recipientEmail: string | null; onDone: () => void; }) { const [header, setHeader] = useState(() => { @@ -82,6 +84,8 @@ function BillingDialogContent({ const [previewUrl, setPreviewUrl] = useState(null); const [previewPending, setPreviewPending] = useState(false); const [createPending, startCreate] = useTransition(); + const [sendEmail, setSendEmail] = useState(false); + const [mailError, setMailError] = useState(null); useEffect(() => { return () => { @@ -162,11 +166,15 @@ function BillingDialogContent({ if (!draft) return; startCreate(async () => { - const result = await createInvoice(draft); + const result = await createInvoice(draft, { sendEmail: sendEmail && Boolean(recipientEmail) }); if ("error" in result) { setError(result.error); return; } + if (result.mailError) { + setMailError(result.mailError); + return; + } onDone(); }); } @@ -177,6 +185,37 @@ function BillingDialogContent({ ); + const sendToggle = recipientEmail ? ( + + ) : null; + + if (mailError) { + return ( + <> + + Rechnung erstellt + +

+ {mailError} Die Rechnung wurde trotzdem angelegt. Du kannst sie in der Tabelle erneut per E-Mail senden. +

+ + + + + ); + } + if (previewUrl) { return ( <> @@ -184,6 +223,7 @@ function BillingDialogContent({ Vorschau