commit b63ce058a066a1d4adfc2c9816ea56efc1992192 Author: Joachim Hummel Date: Fri Apr 17 08:11:54 2026 +0000 feat: Projekt-Scaffold + Core Utilities — Result Pattern, hashEmail, Zod-Schemas, Campaign-Typen Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c26645d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +.env +.env.local +.env.*.local +*.log +pnpm-lock.yaml diff --git a/package.json b/package.json new file mode 100644 index 0000000..3231ca4 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "newsletter-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "lint": "next lint", + "test": "vitest run" + }, + "dependencies": { + "next": "14.2.5", + "react": "^18", + "react-dom": "^18", + "pg": "^8.12.0", + "@clickhouse/client": "^1.4.0", + "bullmq": "^5.12.0", + "nodemailer": "^6.9.14", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/pg": "^8.11.6", + "@types/nodemailer": "^6.4.15", + "typescript": "^5", + "vitest": "^2.0.5", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^8", + "eslint-config-next": "14.2.5", + "tailwindcss": "^3.4.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38" + } +} diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts new file mode 100644 index 0000000..19b72c3 --- /dev/null +++ b/src/lib/crypto.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { hashEmail } from './crypto' + +describe('hashEmail', () => { + it('gibt SHA256-Hash zurück', () => { + const hash = hashEmail('test@example.com') + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + it('normalisiert E-Mail vor Hash (lowercase)', () => { + expect(hashEmail('Test@Example.COM')).toBe(hashEmail('test@example.com')) + }) + + it('gibt nie die Klartext-E-Mail zurück', () => { + const hash = hashEmail('test@example.com') + expect(hash).not.toContain('@') + }) +}) diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..4393a0f --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,5 @@ +import { createHash } from 'crypto' + +export function hashEmail(email: string): string { + return createHash('sha256').update(email.toLowerCase().trim()).digest('hex') +} diff --git a/src/lib/result.test.ts b/src/lib/result.test.ts new file mode 100644 index 0000000..49d785b --- /dev/null +++ b/src/lib/result.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { ok, err } from './result' + +describe('Result', () => { + it('ok wraps data', () => { + const r = ok('hello') + expect(r.ok).toBe(true) + if (r.ok) expect(r.data).toBe('hello') + }) + + it('err wraps error', () => { + const r = err(new Error('fail')) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error.message).toBe('fail') + }) +}) diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 0000000..2c37832 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,6 @@ +export type Result = + | { ok: true; data: T } + | { ok: false; error: E } + +export const ok = (data: T): Result => ({ ok: true, data }) +export const err = (error: E): Result => ({ ok: false, error }) diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..1bc76c1 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const CreateCampaignSchema = z.object({ + name: z.string().min(1).max(255), + subject: z.string().min(1).max(998), + htmlBody: z.string().min(1), + plainBody: z.string().min(1), +}) + +export const UpdateCampaignSchema = CreateCampaignSchema.partial() + +export const ScheduleCampaignSchema = z.union([ + z.object({ type: z.literal('immediate') }), + z.object({ type: z.literal('once'), scheduledAt: z.coerce.date() }), + z.object({ type: z.literal('cron'), cronExpression: z.string().min(9) }), +]) + +export const RecipientSchema = z.union([ + z.object({ listId: z.string().uuid(), segmentId: z.null().optional() }), + z.object({ segmentId: z.string().uuid(), listId: z.null().optional() }), +]) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..2491f9c --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,48 @@ +export type CampaignStatus = + | 'draft' + | 'scheduled' + | 'sending' + | 'sent' + | 'paused' + | 'cancelled' + +export type TriggerType = 'cron' | 'event' + +export interface Campaign { + id: string + name: string + subject: string + htmlBody: string + plainBody: string + status: CampaignStatus + scheduledAt: Date | null + cronExpression: string | null + createdAt: Date + updatedAt: Date +} + +export interface CampaignRecipient { + id: string + campaignId: string + listId: string | null + segmentId: string | null +} + +export interface CampaignTrigger { + id: string + campaignId: string + triggerType: TriggerType + triggerValue: string +} + +export interface CampaignAnalytics { + campaignId: string + sent: number + opens: number + clicks: number + bounces: number + unsubscribes: number + openRate: number + clickRate: number + bounceRate: number +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..49e4cf3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..00623c9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ + test: { + environment: 'node', + globals: false, + }, +})