feat: Projekt-Scaffold + Core Utilities — Result Pattern, hashEmail, Zod-Schemas, Campaign-Typen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:11:54 +00:00
commit b63ce058a0
10 changed files with 185 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
.env
.env.local
.env.*.local
*.log
pnpm-lock.yaml

36
package.json Normal file
View File

@@ -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"
}
}

19
src/lib/crypto.test.ts Normal file
View File

@@ -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('@')
})
})

5
src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createHash } from 'crypto'
export function hashEmail(email: string): string {
return createHash('sha256').update(email.toLowerCase().trim()).digest('hex')
}

16
src/lib/result.test.ts Normal file
View File

@@ -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')
})
})

6
src/lib/result.ts Normal file
View File

@@ -0,0 +1,6 @@
export type Result<T, E = Error> =
| { ok: true; data: T }
| { ok: false; error: E }
export const ok = <T>(data: T): Result<T> => ({ ok: true, data })
export const err = <E = Error>(error: E): Result<never, E> => ({ ok: false, error })

21
src/lib/validation.ts Normal file
View File

@@ -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() }),
])

48
src/types/index.ts Normal file
View File

@@ -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
}

20
tsconfig.json Normal file
View File

@@ -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"]
}

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: false,
},
})