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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
pnpm-lock.yaml
|
||||||
36
package.json
Normal file
36
package.json
Normal 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
19
src/lib/crypto.test.ts
Normal 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
5
src/lib/crypto.ts
Normal 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
16
src/lib/result.test.ts
Normal 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
6
src/lib/result.ts
Normal 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
21
src/lib/validation.ts
Normal 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
48
src/types/index.ts
Normal 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
20
tsconfig.json
Normal 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
7
vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user