From d2f960207e48b5c53dc02e3e0472a94e129d5914 Mon Sep 17 00:00:00 2001 From: Do Siki Date: Sat, 12 Sep 2026 01:25:05 +0200 Subject: [PATCH] feat(cms): ContactSubmissions Payload collection (MITHOME-94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- proto/src/app/api/contact/route.ts | 38 +++++-------- proto/src/app/api/contact/route.unit.test.ts | 43 +++++++++----- proto/src/collections/ContactSubmissions.ts | 59 ++++++++++++++++++++ proto/src/lib/mongodb.test.ts | 10 ---- proto/src/lib/mongodb.ts | 13 +++-- proto/src/payload.config.ts | 7 ++- 6 files changed, 116 insertions(+), 54 deletions(-) create mode 100644 proto/src/collections/ContactSubmissions.ts diff --git a/proto/src/app/api/contact/route.ts b/proto/src/app/api/contact/route.ts index a5e7baa..01719ef 100755 --- a/proto/src/app/api/contact/route.ts +++ b/proto/src/app/api/contact/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { getCollection } from '@/lib/mongodb' +import { getPayload } from 'payload' +import config from '@payload-config' interface ContactFormData { name: string @@ -9,16 +10,6 @@ interface ContactFormData { 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 const rateLimitMap = new Map() const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute @@ -127,20 +118,21 @@ export async function POST(request: NextRequest) { ) } - const submissions = await getCollection('contact_submissions') - const submission: ContactSubmission = { - ...sanitizedData, - gdprConsent: true, - status: 'new', - createdAt: new Date(), - } - const result = await submissions.insertOne(submission) - + const payload = await getPayload({ config }) + const submission = await payload.create({ + collection: 'contact-submissions', + data: { + ...sanitizedData, + gdprConsent: true, + status: 'new', + }, + }) + return NextResponse.json( - { + { message: 'Üzenet sikeresen elküldve!', - timestamp: submission.createdAt.toISOString(), - submissionId: result.insertedId.toHexString() + timestamp: submission.createdAt, + submissionId: String(submission.id) }, { status: 200 } ) diff --git a/proto/src/app/api/contact/route.unit.test.ts b/proto/src/app/api/contact/route.unit.test.ts index e66212c..756c4f2 100755 --- a/proto/src/app/api/contact/route.unit.test.ts +++ b/proto/src/app/api/contact/route.unit.test.ts @@ -3,8 +3,9 @@ * These tests focus on testing the business logic without complex mocking */ import { POST } from './route' +import { getPayload } from 'payload' -const mockInsertOne = jest.fn() +const mockCreate = jest.fn() // Mock the logger to avoid complex setup jest.mock('@/lib/logger', () => ({ @@ -15,16 +16,28 @@ jest.mock('@/lib/logger', () => ({ }) })) -jest.mock('@/lib/mongodb', () => ({ - getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })), +// WHY mock the resolved relative path instead of the '@payload-config' alias: +// 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', () => { beforeEach(() => { - mockInsertOne.mockReset() + mockCreate.mockReset() + ;(getPayload as jest.Mock).mockClear() }) - describe('MongoDB persistence', () => { + describe('Payload persistence', () => { const validData = { name: 'Test User', email: 'test@example.com', @@ -34,7 +47,7 @@ describe('/api/contact Unit Tests', () => { } 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 = { headers: new Headers({ 'x-forwarded-for': 'persistence-success' }), json: async () => validData, @@ -45,15 +58,17 @@ describe('/api/contact Unit Tests', () => { expect(response.status).toBe(200) expect(body.submissionId).toBe('submission-123') - expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({ - ...validData, - status: 'new', - createdAt: expect.any(Date), - })) + expect(mockCreate).toHaveBeenCalledWith({ + collection: 'contact-submissions', + data: expect.objectContaining({ + ...validData, + status: 'new', + }), + }) }) it('returns a server error when persistence fails', async () => { - mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable')) + mockCreate.mockRejectedValue(new Error('MongoDB unavailable')) const request = { headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }), json: async () => validData, @@ -62,7 +77,7 @@ describe('/api/contact Unit Tests', () => { const response = await POST(request) expect(response.status).toBe(500) - expect(mockInsertOne).toHaveBeenCalledTimes(1) + expect(mockCreate).toHaveBeenCalledTimes(1) }) it('rejects an over-long message before persisting', async () => { @@ -75,7 +90,7 @@ describe('/api/contact Unit Tests', () => { const response = await POST(request) expect(response.status).toBe(400) - expect(mockInsertOne).not.toHaveBeenCalled() + expect(mockCreate).not.toHaveBeenCalled() }) }) diff --git a/proto/src/collections/ContactSubmissions.ts b/proto/src/collections/ContactSubmissions.ts new file mode 100644 index 0000000..7785379 --- /dev/null +++ b/proto/src/collections/ContactSubmissions.ts @@ -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.', + }, + }, + ], +} diff --git a/proto/src/lib/mongodb.test.ts b/proto/src/lib/mongodb.test.ts index ce945c3..62d64b8 100755 --- a/proto/src/lib/mongodb.test.ts +++ b/proto/src/lib/mongodb.test.ts @@ -93,16 +93,6 @@ describe('MongoDB Connection (Unit Tests)', () => { 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 () => { const { checkMongoConnection } = await import('./mongodb') diff --git a/proto/src/lib/mongodb.ts b/proto/src/lib/mongodb.ts index beba78b..24926c3 100755 --- a/proto/src/lib/mongodb.ts +++ b/proto/src/lib/mongodb.ts @@ -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 = { maxPoolSize: 10, @@ -50,11 +56,6 @@ export async function getDb(): Promise { return client.db(process.env.MONGODB_DB || 'mozdit') } -export async function getCollection(collectionName: string): Promise> { - const db = await getDb() - return db.collection(collectionName) -} - // Health check for MongoDB connection export async function checkMongoConnection(): Promise { try { diff --git a/proto/src/payload.config.ts b/proto/src/payload.config.ts index bdbc74f..459b143 100644 --- a/proto/src/payload.config.ts +++ b/proto/src/payload.config.ts @@ -17,6 +17,10 @@ * MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header * slotba regisztrálva — teljes szöveges keresés minden Global/Collection * 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 { fileURLToPath } from 'url' @@ -29,6 +33,7 @@ import { Users } from './collections/Users' import { LegalPages } from './collections/LegalPages' import { Media } from './collections/Media' import { Partners } from './collections/Partners' +import { ContactSubmissions } from './collections/ContactSubmissions' import { Home } from './globals/Home' import { About } from './globals/About' import { Services } from './globals/Services' @@ -55,7 +60,7 @@ export default buildConfig({ editor: lexicalEditor(), - collections: [Users, LegalPages, Media, Partners], + collections: [Users, LegalPages, Media, Partners, ContactSubmissions], globals: [Home, About, Services, Contact, Common],