feat: initial SchwarmbLog monorepo setup

- React + Vite + TanStack Router/Query frontend
- Hono + Prisma + MySQL backend
- Prisma schema mit allen Entitäten
- Docker + docker-compose Setup
- Tailwind mit Salm/Ozean Farbpalette
This commit is contained in:
Sebastian
2026-03-29 15:19:47 +00:00
commit ab76f207ae
28 changed files with 768 additions and 0 deletions

3
apps/api/.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL="mysql://user:password@localhost:3306/schwarmblog"
JWT_SECRET="change-me-in-production"
PORT=3001

24
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20-alpine AS base
RUN corepack enable pnpm
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml ./
COPY apps/api/package.json ./apps/api/
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY . .
WORKDIR /app/apps/api
RUN pnpm db:generate
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=build /app/apps/api/dist ./dist
COPY --from=build /app/apps/api/node_modules ./node_modules
EXPOSE 3001
CMD ["node", "dist/index.js"]

32
apps/api/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@schwarmblog/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.14.0",
"hono": "^4.4.0",
"@hono/node-server": "^1.12.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"uuid": "^10.0.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/uuid": "^10.0.0",
"@types/node": "^20.14.0",
"prisma": "^5.14.0",
"tsx": "^4.15.0",
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,260 @@
// SchwarmbLog Prisma Schema
// Stack: Hono + Prisma + MySQL
// Conventions: camelCase in Prisma, snake_case in DB, cuid() for IDs
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// ─────────────────────────────────────────────
// USERS & AUTH
// ─────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String
email String? @unique
role Role @default(STUDENT)
// Auth
passwordHash String?
qrToken String? @unique @map("qr_token")
qrTokenCreated DateTime? @map("qr_token_created")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
classMemberships ClassMembership[]
swarmMemberships SwarmMembership[]
blog Blog?
assignedTodos Todo[] @relation("AssignedTodos")
reactions Reaction[]
comments Comment[]
@@map("users")
}
enum Role {
ADMIN
TEACHER
STUDENT
PARENT
}
// ─────────────────────────────────────────────
// CLASSES & SWARMS
// ─────────────────────────────────────────────
model Class {
id String @id @default(cuid())
name String // z.B. "7a"
schoolYear String @map("school_year") // z.B. "2025/26"
createdAt DateTime @default(now()) @map("created_at")
memberships ClassMembership[]
swarms Swarm[]
blogs Blog[]
@@map("classes")
}
model ClassMembership {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String @map("user_id")
class Class @relation(fields: [classId], references: [id])
classId String @map("class_id")
role ClassRole @default(STUDENT)
@@unique([userId, classId])
@@map("class_memberships")
}
enum ClassRole {
TEACHER
STUDENT
}
// Schwarm = Sichtbarkeitsgruppe (Klasse oder ganzer Jahrgang)
model Swarm {
id String @id @default(cuid())
name String // z.B. "Schwarm 7a" oder "Schwarm Jahrgang 7"
scope SwarmScope
class Class? @relation(fields: [classId], references: [id])
classId String? @map("class_id")
createdAt DateTime @default(now()) @map("created_at")
memberships SwarmMembership[]
@@map("swarms")
}
enum SwarmScope {
CLASS // nur eine Klasse
YEAR // ganzer Jahrgang
}
model SwarmMembership {
id String @id @default(cuid())
swarm Swarm @relation(fields: [swarmId], references: [id])
swarmId String @map("swarm_id")
user User @relation(fields: [userId], references: [id])
userId String @map("user_id")
@@unique([swarmId, userId])
@@map("swarm_memberships")
}
// ─────────────────────────────────────────────
// STATIONEN & TEMPLATES
// ─────────────────────────────────────────────
model Station {
id String @id @default(cuid())
name String // z.B. "Lagos"
country String
latitude Float
longitude Float
bneTopic String @map("bne_topic") // z.B. "Elektroschrott"
description String? @db.Text
orderIndex Int @map("order_index") // optimale Reihenfolge
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
templates EntryTemplate[]
blogEntries BlogEntry[]
@@map("stations")
}
// Vorstrukturierter Eintrag (Lückentext, Bildblock, etc.)
// blocks: JSON-Array von Block-Objekten
// Beispiel: [{ type: "gap_text", content: "Ich bin in ___ angekommen..." }, { type: "image" }, { type: "free_text" }]
model EntryTemplate {
id String @id @default(cuid())
station Station @relation(fields: [stationId], references: [id])
stationId String @map("station_id")
title String
blocks Json // Block-Struktur des Templates
isOptional Boolean @default(false) @map("is_optional")
orderIndex Int @default(0) @map("order_index")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
todos Todo[]
blogEntries BlogEntry[]
@@map("entry_templates")
}
// ─────────────────────────────────────────────
// BLOG & EINTRÄGE
// ─────────────────────────────────────────────
model Blog {
id String @id @default(cuid())
student User @relation(fields: [studentId], references: [id])
studentId String @unique @map("student_id")
class Class @relation(fields: [classId], references: [id])
classId String @map("class_id")
title String @default("Mein Reiseblog")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
entries BlogEntry[]
todos Todo[]
@@map("blogs")
}
model BlogEntry {
id String @id @default(cuid())
blog Blog @relation(fields: [blogId], references: [id])
blogId String @map("blog_id")
station Station? @relation(fields: [stationId], references: [id])
stationId String? @map("station_id")
template EntryTemplate? @relation(fields: [templateId], references: [id])
templateId String? @map("template_id") // null = freier Eintrag
title String
blocks Json // Ausgefüllte Block-Inhalte (Text, Bildpfad, etc.)
status EntryStatus @default(DRAFT)
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
reactions Reaction[]
comments Comment[]
@@map("blog_entries")
}
enum EntryStatus {
DRAFT
PUBLISHED
}
enum Visibility {
PRIVATE // nur Schüler + Lehrkraft
CLASS // Klassen-Schwarm
YEAR // Jahrgangs-Schwarm
PARENTS // zusätzlich Eltern
}
// ─────────────────────────────────────────────
// TODOS
// ─────────────────────────────────────────────
// Lehrkraft weist Schüler einen vorstrukturierten Eintrag zu
model Todo {
id String @id @default(cuid())
blog Blog @relation(fields: [blogId], references: [id])
blogId String @map("blog_id")
template EntryTemplate @relation(fields: [templateId], references: [id])
templateId String @map("template_id")
assignedBy User @relation("AssignedTodos", fields: [assignedById], references: [id])
assignedById String @map("assigned_by")
dueDate DateTime? @map("due_date")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("todos")
}
// ─────────────────────────────────────────────
// SOZIALES
// ─────────────────────────────────────────────
model Reaction {
id String @id @default(cuid())
entry BlogEntry @relation(fields: [entryId], references: [id])
entryId String @map("entry_id")
user User @relation(fields: [userId], references: [id])
userId String @map("user_id")
type ReactionType @default(LIKE)
@@unique([entryId, userId]) // pro Eintrag nur eine Reaktion pro User
@@map("reactions")
}
enum ReactionType {
LIKE
}
model Comment {
id String @id @default(cuid())
entry BlogEntry @relation(fields: [entryId], references: [id])
entryId String @map("entry_id")
user User @relation(fields: [userId], references: [id])
userId String @map("user_id")
text String @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("comments")
}

30
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,30 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import "dotenv/config";
const app = new Hono();
// Middleware
app.use("*", logger());
app.use(
"*",
cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:5173",
credentials: true,
})
);
// Health check
app.get("/health", (c) => c.json({ status: "ok", app: "SchwarmbLog API" }));
// Routes (werden schrittweise ergänzt)
// app.route("/auth", authRoutes);
// app.route("/stations", stationRoutes);
// app.route("/blogs", blogRoutes);
const port = Number(process.env.PORT ?? 3001);
console.log(`🐟 SchwarmbLog API läuft auf Port ${port}`);
serve({ fetch: app.fetch, port });

13
apps/api/src/lib/db.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

View File

@@ -0,0 +1,26 @@
import { createMiddleware } from "hono/factory";
import jwt from "jsonwebtoken";
export type AuthUser = {
id: string;
role: string;
};
declare module "hono" {
interface ContextVariableMap {
user: AuthUser;
}
}
export const requireAuth = createMiddleware(async (c, next) => {
const token = c.req.header("Authorization")?.replace("Bearer ", "");
if (!token) return c.json({ error: "Unauthorized" }, 401);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET ?? "") as AuthUser;
c.set("user", payload);
await next();
} catch {
return c.json({ error: "Invalid token" }, 401);
}
});

9
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"moduleResolution": "node"
},
"include": ["src"]
}