feat: contact sayfasına iletişim formu + POST /contact endpoint'i
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

- Yeni contact modülü: @Public POST /contact, zod validation, @Throttle 5/10dk
  spam koruması; EmailService ile admin@sase.tr'ye mail (reply-to = gönderen),
  kullanıcı girdileri HTML-escape
- EmailService: replyTo desteği eklendi
- contact.tsx: useState + zod + @sase/ui ile iletişim formu (mevcut form
  pattern'iyle tutarlı; yeni form kütüphanesi yok), toast + alan validasyonu

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 01:27:56 +03:00
parent 14a1af0f81
commit 57b3cb7ebe
7 changed files with 293 additions and 59 deletions

View File

@@ -8,11 +8,11 @@ import { SentryModule } from "@sentry/nestjs/setup";
import { AdminModule } from "./admin/admin.module";
import { AnalyticsModule } from "./analytics/analytics.module";
import { AuthModule } from "./auth/auth.module";
import { BlogModule } from "./blog/blog.module";
import { BrandsModule } from "./brands/brands.module";
import { CatalogModule } from "./catalog/catalog.module";
import { CategoriesModule } from "./categories/categories.module";
import { ChangelogModule } from "./changelog/changelog.module";
import { BlogModule } from "./blog/blog.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { AuthGuard } from "./common/guards/auth.guard";
import { ImpersonationReadonlyGuard } from "./common/guards/impersonation-readonly.guard";
@@ -22,11 +22,12 @@ import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
import configuration from "./config/configuration";
import { validate } from "./config/env.validation";
import { ContactModule } from "./contact/contact.module";
import { DatabaseModule } from "./database/database.module";
import { EmailModule } from "./email/email.module";
import { HealthController } from "./health.controller";
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { EmexModule } from "./integrations/emex/emex.module";
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
@@ -87,6 +88,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
CatalogModule,
ChangelogModule,
BlogModule,
ContactModule,
PostHogModule,
TelemetryModule,
InternalAdminModule,

View File

@@ -0,0 +1,17 @@
import { Body, Controller, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { Public } from "../common/decorators/public.decorator";
import { ContactService } from "./contact.service";
@Controller("contact")
export class ContactController {
constructor(private readonly contactService: ContactService) {}
@Post()
@Public()
// Spam koruması: IP başına 10 dakikada en fazla 5 gönderim.
@Throttle({ default: { limit: 5, ttl: 600_000 } })
async submit(@Body() body: unknown) {
return this.contactService.submit(body);
}
}

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const contactMessageSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional().default(""),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
export type ContactMessage = z.infer<typeof contactMessageSchema>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ContactController } from "./contact.controller";
import { ContactService } from "./contact.service";
// EmailService @Global EmailModule'den gelir.
@Module({
controllers: [ContactController],
providers: [ContactService],
})
export class ContactModule {}

View File

@@ -0,0 +1,50 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { EmailService } from "../email/email.service";
import { contactMessageSchema } from "./contact.dto";
// İletişim formu mesajlarının gönderileceği adres.
const CONTACT_RECIPIENT = "admin@sase.tr";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
@Injectable()
export class ContactService {
private readonly logger = new Logger(ContactService.name);
constructor(private readonly email: EmailService) {}
async submit(body: unknown): Promise<{ success: true }> {
const parsed = contactMessageSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz form verisi");
}
const { name, email, message } = parsed.data;
const subject = parsed.data.subject?.trim() || "Yeni mesaj";
await this.email.send({
to: CONTACT_RECIPIENT,
replyTo: email, // admin doğrudan gönderene yanıt verebilsin
subject: `İletişim Formu: ${subject}`,
tag: "contact-form",
html: `
<h2>Yeni iletişim formu mesajı</h2>
<p><strong>Ad:</strong> ${escapeHtml(name)}</p>
<p><strong>E-posta:</strong> ${escapeHtml(email)}</p>
<p><strong>Konu:</strong> ${escapeHtml(subject)}</p>
<p><strong>Mesaj:</strong></p>
<p style="white-space: pre-wrap">${escapeHtml(message)}</p>
`,
text: `Ad: ${name}\nE-posta: ${email}\nKonu: ${subject}\n\nMesaj:\n${message}`,
});
this.logger.log(`Contact form submitted by ${email}`);
return { success: true };
}
}

View File

@@ -7,6 +7,7 @@ export interface SendEmailOptions {
html: string;
text?: string;
tag?: string;
replyTo?: string;
}
interface PostalApiResponse {
@@ -48,6 +49,7 @@ export class EmailService {
html_body: options.html,
...(options.text && { plain_body: options.text }),
...(options.tag && { tag: options.tag }),
...(options.replyTo && { reply_to: options.replyTo }),
};
try {