feat(cms): ContactSubmissions Payload collection (MITHOME-94)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s

/api/contact used to write straight to a raw, Payload-external MongoDB
collection (contact_submissions, via proto/src/lib/mongodb.ts's
getCollection) — the client had no way to see incoming messages except
by reading the database directly. Rate limiting, length caps, email
validation, and spam-keyword filtering all stay on the route exactly
as before; only the persistence target changed.

New: src/collections/ContactSubmissions.ts (name, email, subject,
message, gdprConsent checkbox, status select defaulting to "new").
Deliberately no custom `access` block — Payload's default
(authenticated-only for every REST operation) is exactly right here:
the client reads submissions in the admin, nobody can read or write
them through the public REST API, and the route's own write uses the
Local API (payload.create), which runs with overrideAccess: true by
default and so isn't blocked by that same rule. No versions/drafts
(a submission is a fact, not editable content) and no field-level
length/format validation duplicated in the collection, matching the
ticket's explicit scope: those checks live on the route.

route.ts: replaced getCollection()/insertOne() with
getPayload({config}).create({ collection: 'contact-submissions', ... }).
Removed the now-unused getCollection() helper from lib/mongodb.ts
(checkMongoConnection/getDb stay, used by /api/health) and its test.

Test gotcha worth documenting: next/jest's SWC transform rewrites the
`@payload-config` tsconfig-path alias to a real relative specifier at
transform time, so `jest.mock('@payload-config', ...)` never actually
intercepts what route.ts requires — it silently falls through to the
real payload.config.ts (mongooseAdapter, live Mongo needed). Fixed by
mocking the resolved relative path instead
(`jest.mock('../../../payload.config', ...)`); documented inline in
route.unit.test.ts for whoever hits this next (MITHOME-96 will need
the same trick for other Payload-backed routes/collections).

Verified live end-to-end, not just the test suite: submitted the real
contact form on /hu/kapcsolat, got the success message, found the
submission in /admin/collections/contact-submissions with all fields
correct (including gdprConsent checked and status "Új"/New), then
deleted the test record. Zero console errors in a fresh tab. Gate:
tsc, lint, unit tests (50 passed — one fewer than before, the removed
getCollection test), production build all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Do Siki
2026-09-12 01:25:05 +02:00
co-authored by Claude Sonnet 5
parent d60c38f65a
commit d2f960207e
6 changed files with 116 additions and 54 deletions
+13 -21
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getCollection } from '@/lib/mongodb' import { getPayload } from 'payload'
import config from '@payload-config'
interface ContactFormData { interface ContactFormData {
name: string name: string
@@ -9,16 +10,6 @@ interface ContactFormData {
gdprConsent: boolean gdprConsent: boolean
} }
interface ContactSubmission {
name: string
email: string
subject: string
message: string
gdprConsent: true
status: 'new'
createdAt: Date
}
// Simple spam protection - rate limiting by IP // Simple spam protection - rate limiting by IP
const rateLimitMap = new Map<string, { count: number; timestamp: number }>() const rateLimitMap = new Map<string, { count: number; timestamp: number }>()
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
@@ -127,20 +118,21 @@ export async function POST(request: NextRequest) {
) )
} }
const submissions = await getCollection<ContactSubmission>('contact_submissions') const payload = await getPayload({ config })
const submission: ContactSubmission = { const submission = await payload.create({
...sanitizedData, collection: 'contact-submissions',
gdprConsent: true, data: {
status: 'new', ...sanitizedData,
createdAt: new Date(), gdprConsent: true,
} status: 'new',
const result = await submissions.insertOne(submission) },
})
return NextResponse.json( return NextResponse.json(
{ {
message: 'Üzenet sikeresen elküldve!', message: 'Üzenet sikeresen elküldve!',
timestamp: submission.createdAt.toISOString(), timestamp: submission.createdAt,
submissionId: result.insertedId.toHexString() submissionId: String(submission.id)
}, },
{ status: 200 } { status: 200 }
) )
+29 -14
View File
@@ -3,8 +3,9 @@
* These tests focus on testing the business logic without complex mocking * These tests focus on testing the business logic without complex mocking
*/ */
import { POST } from './route' import { POST } from './route'
import { getPayload } from 'payload'
const mockInsertOne = jest.fn() const mockCreate = jest.fn()
// Mock the logger to avoid complex setup // Mock the logger to avoid complex setup
jest.mock('@/lib/logger', () => ({ jest.mock('@/lib/logger', () => ({
@@ -15,16 +16,28 @@ jest.mock('@/lib/logger', () => ({
}) })
})) }))
jest.mock('@/lib/mongodb', () => ({ // WHY mock the resolved relative path instead of the '@payload-config' alias:
getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })), // next/jest's SWC transform rewrites the tsconfig path alias to a real
// relative specifier at transform time (Jest itself doesn't understand
// tsconfig `paths`), so a `jest.mock('@payload-config', ...)` never actually
// intercepts what route.ts ends up requiring — it silently falls through to
// the real proto/src/payload.config.ts (mongooseAdapter, Users/LegalPages/etc
// imports), which needs a live MongoDB and PAYLOAD_SECRET, exactly what these
// tests avoid. The route only ever passes this value through to the (also
// mocked) getPayload(), so its actual shape doesn't matter here.
jest.mock('../../../payload.config', () => ({ __esModule: true, default: {} }))
jest.mock('payload', () => ({
getPayload: jest.fn(async () => ({ create: mockCreate })),
})) }))
describe('/api/contact Unit Tests', () => { describe('/api/contact Unit Tests', () => {
beforeEach(() => { beforeEach(() => {
mockInsertOne.mockReset() mockCreate.mockReset()
;(getPayload as jest.Mock).mockClear()
}) })
describe('MongoDB persistence', () => { describe('Payload persistence', () => {
const validData = { const validData = {
name: 'Test User', name: 'Test User',
email: 'test@example.com', email: 'test@example.com',
@@ -34,7 +47,7 @@ describe('/api/contact Unit Tests', () => {
} }
it('persists a valid submission before reporting success', async () => { it('persists a valid submission before reporting success', async () => {
mockInsertOne.mockResolvedValue({ insertedId: { toHexString: () => 'submission-123' } }) mockCreate.mockResolvedValue({ id: 'submission-123', createdAt: '2026-09-12T00:00:00.000Z' })
const request = { const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-success' }), headers: new Headers({ 'x-forwarded-for': 'persistence-success' }),
json: async () => validData, json: async () => validData,
@@ -45,15 +58,17 @@ describe('/api/contact Unit Tests', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(body.submissionId).toBe('submission-123') expect(body.submissionId).toBe('submission-123')
expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({ expect(mockCreate).toHaveBeenCalledWith({
...validData, collection: 'contact-submissions',
status: 'new', data: expect.objectContaining({
createdAt: expect.any(Date), ...validData,
})) status: 'new',
}),
})
}) })
it('returns a server error when persistence fails', async () => { it('returns a server error when persistence fails', async () => {
mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable')) mockCreate.mockRejectedValue(new Error('MongoDB unavailable'))
const request = { const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }), headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }),
json: async () => validData, json: async () => validData,
@@ -62,7 +77,7 @@ describe('/api/contact Unit Tests', () => {
const response = await POST(request) const response = await POST(request)
expect(response.status).toBe(500) expect(response.status).toBe(500)
expect(mockInsertOne).toHaveBeenCalledTimes(1) expect(mockCreate).toHaveBeenCalledTimes(1)
}) })
it('rejects an over-long message before persisting', async () => { it('rejects an over-long message before persisting', async () => {
@@ -75,7 +90,7 @@ describe('/api/contact Unit Tests', () => {
const response = await POST(request) const response = await POST(request)
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(mockInsertOne).not.toHaveBeenCalled() expect(mockCreate).not.toHaveBeenCalled()
}) })
}) })
@@ -0,0 +1,59 @@
import type { CollectionConfig } from 'payload'
/**
* MITHOME-94 — a /api/contact beérkező üzeneteinek tárolója.
*
* WHY nincs egyedi `access` blokk: a Payload alapértelmezése (csak
* bejelentkezett usernek engedélyezett minden művelet) itt pont a kívánt
* viselkedés — az ügyfél az admin felületen látja a beérkezéseket, kívülről
* senki nem olvashatja/írhatja a REST API-n keresztül. A src/app/api/contact
* route.ts a Local API-t használja (`payload.create`), ami alapból
* `overrideAccess: true`-val fut — ez a szerver-oldali írás nem ütközik az
* access control-lal, csak a publikus REST hozzáférés van letiltva.
*
* WHY nincs mezőnkénti hosszkorlát/validáció itt duplikálva: a rate limiting
* és a bemenet-validáció (hossz, email formátum, spam-szűrés) szándékosan
* az API route-on marad (lásd a ticket leírását) — ez a collection csak a
* már ellenőrzött adat tárolója.
*
* WHY nincs versions/drafts: ez tényadat (egy beérkezett üzenet), nem
* szerkesztendő tartalom — a `status` mező követi a feldolgozás állapotát.
*/
export const ContactSubmissions: CollectionConfig = {
slug: 'contact-submissions',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'name', 'email', 'status', 'createdAt'],
group: 'Kapcsolatfelvételek',
},
defaultSort: '-createdAt',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'text', required: true },
{ name: 'subject', type: 'text', required: true },
{ name: 'message', type: 'textarea', required: true },
{
name: 'gdprConsent',
type: 'checkbox',
required: true,
admin: {
description: 'Az űrlapon elfogadott adatkezelési hozzájárulás — a route csak true értékkel enged menteni.',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'new',
options: [
{ label: 'Új', value: 'new' },
{ label: 'Elolvasva', value: 'read' },
{ label: 'Megválaszolva', value: 'replied' },
{ label: 'Archiválva', value: 'archived' },
],
admin: {
description: 'Kézzel karbantartott feldolgozási állapot — a route mindig "new"-ként hozza létre.',
},
},
],
}
-10
View File
@@ -93,16 +93,6 @@ describe('MongoDB Connection (Unit Tests)', () => {
expect(typeof result.collection).toBe('function') expect(typeof result.collection).toBe('function')
}) })
it('should return collection from database', async () => {
const { getCollection } = await import('./mongodb')
const collection = await getCollection('test_collection')
expect(collection).toBeDefined()
expect(mockDb.collection).toHaveBeenCalledWith('test_collection')
expect(typeof collection.findOne).toBe('function')
})
it('should check MongoDB connection successfully', async () => { it('should check MongoDB connection successfully', async () => {
const { checkMongoConnection } = await import('./mongodb') const { checkMongoConnection } = await import('./mongodb')
+7 -6
View File
@@ -1,4 +1,10 @@
import { MongoClient, Db, Collection, Document } from 'mongodb' import { MongoClient, Db } from 'mongodb'
// WHY this file only exposes getDb/checkMongoConnection now: it used to also
// export getCollection(), used by src/app/api/contact/route.ts for a raw,
// Payload-external `contact_submissions` collection. MITHOME-94 moved that
// write to a proper Payload collection (ContactSubmissions) via the Local
// API — getDb() itself stays alive only for /api/health's connectivity check.
const options = { const options = {
maxPoolSize: 10, maxPoolSize: 10,
@@ -50,11 +56,6 @@ export async function getDb(): Promise<Db> {
return client.db(process.env.MONGODB_DB || 'mozdit') return client.db(process.env.MONGODB_DB || 'mozdit')
} }
export async function getCollection<T extends Document = Document>(collectionName: string): Promise<Collection<T>> {
const db = await getDb()
return db.collection<T>(collectionName)
}
// Health check for MongoDB connection // Health check for MongoDB connection
export async function checkMongoConnection(): Promise<boolean> { export async function checkMongoConnection(): Promise<boolean> {
try { try {
+6 -1
View File
@@ -17,6 +17,10 @@
* MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header * MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header
* slotba regisztrálva — teljes szöveges keresés minden Global/Collection * slotba regisztrálva — teljes szöveges keresés minden Global/Collection
* mezőjében, mindkét locale-ban. * mezőjében, mindkét locale-ban.
* MITHOME-94: ContactSubmissions collection — a /api/contact korábban egy
* nyers, Payload-on kívüli Mongo collection-be (`contact_submissions`,
* proto/src/lib/mongodb.ts) írt, amit az ügyfél nem látott sehol az admin
* felületen. Mostantól Payload collection, a route.ts a Local API-n ír bele.
*/ */
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -29,6 +33,7 @@ import { Users } from './collections/Users'
import { LegalPages } from './collections/LegalPages' import { LegalPages } from './collections/LegalPages'
import { Media } from './collections/Media' import { Media } from './collections/Media'
import { Partners } from './collections/Partners' import { Partners } from './collections/Partners'
import { ContactSubmissions } from './collections/ContactSubmissions'
import { Home } from './globals/Home' import { Home } from './globals/Home'
import { About } from './globals/About' import { About } from './globals/About'
import { Services } from './globals/Services' import { Services } from './globals/Services'
@@ -55,7 +60,7 @@ export default buildConfig({
editor: lexicalEditor(), editor: lexicalEditor(),
collections: [Users, LegalPages, Media, Partners], collections: [Users, LegalPages, Media, Partners, ContactSubmissions],
globals: [Home, About, Services, Contact, Common], globals: [Home, About, Services, Contact, Common],