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:
3
apps/api/.env.example
Normal file
3
apps/api/.env.example
Normal 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
24
apps/api/Dockerfile
Normal 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
32
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
260
apps/api/prisma/schema.prisma
Normal file
260
apps/api/prisma/schema.prisma
Normal 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
30
apps/api/src/index.ts
Normal 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
13
apps/api/src/lib/db.ts
Normal 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;
|
||||
26
apps/api/src/middleware/auth.ts
Normal file
26
apps/api/src/middleware/auth.ts
Normal 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
9
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user