Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation

- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien)
- ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten
- DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views
- USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister)
- PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen
- BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution
- ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs
- COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung
- API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions
- ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall
- GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung
- AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz
- DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response
- FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Knuth
2026-02-18 19:03:37 +01:00
parent fc68285cf1
commit fca42db4d2
116 changed files with 9329 additions and 6479 deletions

View File

@@ -0,0 +1,34 @@
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { magicLink } from 'better-auth/plugins'
import { admin as adminPlugin } from 'better-auth/plugins'
import { prisma } from '@innungsapp/shared'
import { sendMagicLinkEmail } from './email'
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!,
trustedOrigins: [
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000',
],
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendMagicLinkEmail({ to: email, magicUrl: url })
},
}),
adminPlugin(),
],
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes cache
},
},
})
export type Auth = typeof auth

View File

@@ -0,0 +1,90 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth:
process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
})
export async function sendMagicLinkEmail({
to,
magicUrl,
}: {
to: string
magicUrl: string
}) {
await transporter.sendMail({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to,
subject: 'Ihr Login-Link für InnungsApp',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
</div>
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #111827; margin-top: 0;">Ihr persönlicher Login-Link</h2>
<p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p>
<a href="${magicUrl}"
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
Jetzt einloggen
</a>
<p style="color: #9ca3af; font-size: 14px;">
Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren.
</p>
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
InnungsApp · Die digitale Plattform für Innungen
</p>
</div>
</div>
`,
})
}
export async function sendInviteEmail({
to,
memberName,
orgName,
apiUrl,
}: {
to: string
memberName: string
orgName: string
apiUrl: string
}) {
// Generate magic link for the invite
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
await transporter.sendMail({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to,
subject: `Einladung zur InnungsApp — ${orgName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
</div>
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #111827; margin-top: 0;">Hallo ${memberName},</h2>
<p style="color: #4b5563;">
Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen.
InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis.
</p>
<p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p>
<a href="${signInUrl}"
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
Jetzt Zugang aktivieren
</a>
<p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig — Sie erhalten einen sicheren Login-Link per E-Mail.</p>
</div>
</div>
`,
})
}

View File

@@ -0,0 +1,45 @@
import { prisma } from '@innungsapp/shared'
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
/**
* Send push notifications to all active members in an org who have a push token
*/
export async function sendPushNotifications(orgId: string, title: string, body?: string) {
const members = await prisma.member.findMany({
where: { orgId, status: 'aktiv', pushToken: { not: null } },
select: { pushToken: true },
})
const tokens = members
.map((m) => m.pushToken)
.filter((t): t is string => t !== null)
if (tokens.length === 0) return
// Expo Push API supports batches of up to 100
const batches: string[][] = []
for (let i = 0; i < tokens.length; i += 100) {
batches.push(tokens.slice(i, i + 100))
}
for (const batch of batches) {
const messages = batch.map((token) => ({
to: token,
title,
body: body ?? 'Neue Nachricht von Ihrer Innung',
sound: 'default',
data: { type: 'news' },
}))
await fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Accept-encoding': 'gzip, deflate',
'Content-Type': 'application/json',
},
body: JSON.stringify(messages),
})
}
}

View File

@@ -0,0 +1,6 @@
'use client'
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers'
export const trpc = createTRPCReact<AppRouter>()