Initial commit: everythingbutwork MVP
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets)
und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion),
Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check
und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0.

Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 +
Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv).

Planungs-Artefakte unter _bmad-output/planning-artifacts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-05-25 22:47:26 +02:00
commit 4f33509a0f
131 files changed
+34339

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
// Fastify-Decorator: macht `req.cockpitUser` verfügbar, wenn die Session gültig ist.
// Die Typaugmentation lebt in `../types.ts`, damit sie auch in tRPC-Imports
// gezogen wird (web → api Type-Only-Import).
import '../types.js';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { loadEnv } from '../env.js';
import { getSessionWithUser } from './session.js';
export async function registerAuthMiddleware(app: FastifyInstance): Promise<void> {
const env = loadEnv();
app.decorateRequest('cockpitUser', null);
app.decorateRequest('cockpitSessionId', null);
app.addHook('onRequest', async (req: FastifyRequest) => {
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
if (!sessionId) return;
const found = await getSessionWithUser(sessionId);
if (found) {
req.cockpitUser = found.user;
req.cockpitSessionId = sessionId;
}
});
}
+92
View File
@@ -0,0 +1,92 @@
// Authentik OIDC via openid-client v6. Authorization Code + PKCE.
import * as oidc from 'openid-client';
import { loadEnv } from '../env.js';
import { logger } from '../lib/logger.js';
let cachedConfig: oidc.Configuration | undefined;
export async function getOidcConfig(): Promise<oidc.Configuration> {
if (cachedConfig) return cachedConfig;
const env = loadEnv();
cachedConfig = await oidc.discovery(
new URL(env.AUTHENTIK_ISSUER),
env.AUTHENTIK_CLIENT_ID,
env.AUTHENTIK_CLIENT_SECRET,
);
logger.info({ issuer: env.AUTHENTIK_ISSUER }, 'oidc discovery completed');
return cachedConfig;
}
export interface PendingFlow {
state: string;
nonce: string;
codeVerifier: string;
returnTo: string;
}
export async function buildAuthorizationUrl(returnTo: string): Promise<{
url: URL;
pending: PendingFlow;
}> {
const config = await getOidcConfig();
const env = loadEnv();
const codeVerifier = oidc.randomPKCECodeVerifier();
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: env.AUTHENTIK_REDIRECT_URI,
scope: 'openid profile email groups',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
nonce,
});
return {
url,
pending: { state, nonce, codeVerifier, returnTo },
};
}
export interface OidcUserInfo {
sub: string;
email: string;
name: string;
groups: string[];
}
export async function completeAuthorization(
callbackUrl: URL,
pending: PendingFlow,
): Promise<OidcUserInfo> {
const config = await getOidcConfig();
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
idTokenExpected: true,
});
const claims = tokens.claims();
if (!claims) {
throw new Error('OIDC-Token enthält keine Claims.');
}
const sub = String(claims.sub ?? '');
const email = String(claims.email ?? '');
const name = String(claims.name ?? claims.preferred_username ?? email);
const rawGroups = claims.groups;
const groups: string[] = Array.isArray(rawGroups)
? rawGroups.filter((g): g is string => typeof g === 'string')
: [];
if (!sub || !email) {
throw new Error('OIDC-Claims unvollständig (sub oder email fehlt).');
}
return { sub, email, name, groups };
}
+141
View File
@@ -0,0 +1,141 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { loadEnv } from '../env.js';
import { logger } from '../lib/logger.js';
import { buildAuthorizationUrl, completeAuthorization, type PendingFlow } from './oidc.js';
import { createSession, deleteSession } from './session.js';
import { upsertUserFromOidc } from './user.js';
const PENDING_COOKIE = 'cockpit_oidc_pending';
function setSessionCookie(reply: FastifyReply, sessionId: string): void {
const env = loadEnv();
const maxAge = env.SESSION_TTL_HOURS * 60 * 60;
reply.setCookie(env.SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge,
signed: false,
});
}
function clearSessionCookie(reply: FastifyReply): void {
const env = loadEnv();
reply.clearCookie(env.SESSION_COOKIE_NAME, { path: '/' });
}
function setPendingCookie(reply: FastifyReply, pending: PendingFlow): void {
reply.setCookie(PENDING_COOKIE, JSON.stringify(pending), {
path: '/',
httpOnly: true,
secure: loadEnv().NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60, // 10 Minuten für den Round-Trip
signed: true,
});
}
function readPendingCookie(req: FastifyRequest): PendingFlow | null {
const raw = req.cookies[PENDING_COOKIE];
if (!raw) return null;
const unsigned = req.unsignCookie(raw);
if (!unsigned.valid || !unsigned.value) return null;
try {
const parsed = JSON.parse(unsigned.value) as PendingFlow;
return parsed;
} catch {
return null;
}
}
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
const env = loadEnv();
// Startet den OIDC-Flow.
app.get('/api/auth/login', async (req, reply) => {
const returnTo =
typeof req.query === 'object' && req.query !== null && 'returnTo' in req.query
? String((req.query as Record<string, unknown>).returnTo ?? '/puls')
: '/puls';
const { url, pending } = await buildAuthorizationUrl(returnTo);
setPendingCookie(reply, pending);
return reply.redirect(url.toString(), 302);
});
// OIDC-Callback. Tauscht den Code gegen Tokens und legt eine Session an.
app.get('/api/auth/callback', async (req, reply) => {
const pending = readPendingCookie(req);
if (!pending) {
logger.warn('callback ohne pending-cookie');
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=session_expired`, 302);
}
try {
const callbackUrl = new URL(env.AUTHENTIK_REDIRECT_URI);
for (const [k, v] of Object.entries(req.query as Record<string, string>)) {
callbackUrl.searchParams.set(k, v);
}
const oidcUser = await completeAuthorization(callbackUrl, pending);
const userRow = await upsertUserFromOidc(oidcUser);
const newSession = await createSession({
userId: userRow.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
setSessionCookie(reply, newSession.id);
reply.clearCookie(PENDING_COOKIE, { path: '/' });
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/puls';
return reply.redirect(`${env.PUBLIC_BASE_URL}${target}`, 302);
} catch (err) {
logger.error({ err }, 'oidc callback failed');
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=oidc_failed`, 302);
}
});
app.post('/api/auth/logout', async (req, reply) => {
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
if (sessionId) {
await deleteSession(sessionId);
}
clearSessionCookie(reply);
return { ok: true };
});
// Dev-Auto-Login: legt sofort eine Session für den Admin-Dejan an, sodass
// die App ohne Authentik durchnavigierbar ist. Authentik kommt in der späteren
// Roadmap. Nur aktiv bei NODE_ENV=development.
if (env.NODE_ENV === 'development') {
const handler = async (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => {
const { getDb } = await import('../db.js');
const { user } = await import('@cockpit/db');
const { eq } = await import('drizzle-orm');
const { db } = getDb();
const found = (await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1))[0];
const userRow = found
? found
: await upsertUserFromOidc({
sub: 'local-dejan',
email: 'dejan@tool-ctrl.de',
name: 'Dejan Cosic',
groups: ['entwicklung'],
});
const newSession = await createSession({
userId: userRow.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
setSessionCookie(reply, newSession.id);
return reply.redirect(`${env.PUBLIC_BASE_URL}/puls`, 302);
};
app.get('/api/auth/dev-login', handler);
app.post('/api/auth/dev-login', handler);
}
}
+63
View File
@@ -0,0 +1,63 @@
// Session-Store in Postgres. Browser sieht nur `session_id` als HttpOnly-Cookie.
import { eq, lt } from 'drizzle-orm';
import { session, user, type Session, type User } from '@cockpit/db';
import { getDb } from '../db.js';
import { loadEnv } from '../env.js';
export interface CreateSessionInput {
userId: string;
ipAddress?: string;
userAgent?: string;
}
export async function createSession(input: CreateSessionInput): Promise<Session> {
const env = loadEnv();
const { db } = getDb();
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000);
const [created] = await db
.insert(session)
.values({
userId: input.userId,
expiresAt,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
})
.returning();
if (!created) {
throw new Error('Session konnte nicht angelegt werden.');
}
return created;
}
export async function getSessionWithUser(
sessionId: string,
): Promise<{ session: Session; user: User } | null> {
const { db } = getDb();
const rows = await db
.select({ session, user })
.from(session)
.innerJoin(user, eq(session.userId, user.id))
.where(eq(session.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() <= Date.now()) {
await db.delete(session).where(eq(session.id, sessionId));
return null;
}
return row;
}
export async function deleteSession(sessionId: string): Promise<void> {
const { db } = getDb();
await db.delete(session).where(eq(session.id, sessionId));
}
export async function pruneExpiredSessions(): Promise<number> {
const { db } = getDb();
const result = await db.delete(session).where(lt(session.expiresAt, new Date()));
return result.rowCount ?? 0;
}
+37
View File
@@ -0,0 +1,37 @@
import { eq } from 'drizzle-orm';
import { user, type User } from '@cockpit/db';
import { getDb } from '../db.js';
import type { OidcUserInfo } from './oidc.js';
// MVP: Group-Claim → default_area_id wird in Story 1.x mit echter Area-Tabelle
// gemappt. Aktuell speichern wir den User mit `default_area_id = null` und
// pflegen den Bereich später manuell. (OQ-1.)
export async function upsertUserFromOidc(info: OidcUserInfo): Promise<User> {
const { db } = getDb();
const existing = await db.select().from(user).where(eq(user.authentikSub, info.sub)).limit(1);
if (existing[0]) {
const [updated] = await db
.update(user)
.set({
name: info.name,
email: info.email,
updatedAt: new Date(),
})
.where(eq(user.id, existing[0].id))
.returning();
if (!updated) throw new Error('User-Update fehlgeschlagen.');
return updated;
}
const [created] = await db
.insert(user)
.values({
authentikSub: info.sub,
name: info.name,
email: info.email,
})
.returning();
if (!created) throw new Error('User-Anlage fehlgeschlagen.');
return created;
}
+77
View File
@@ -0,0 +1,77 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { loadEnv } from '../env.js';
import { logger } from '../lib/logger.js';
import { getDb } from '../db.js';
import { publishYesterdaysEntries } from '../routers/day-entry.js';
import { previousWerktag, todayIso } from '../lib/werktag.js';
function authorize(req: FastifyRequest): void {
const env = loadEnv();
const header = req.headers.authorization ?? '';
const expected = `Bearer ${env.CRON_BEARER_TOKEN}`;
if (header !== expected) {
throw new Error('Unauthorized cron call.');
}
}
export async function registerCronRoutes(app: FastifyInstance): Promise<void> {
app.post('/internal/cron/publish-day-entries', async (req, reply) => {
try {
authorize(req);
} catch (err) {
reply.code(401);
logger.warn({ err }, 'unauthorized cron call');
return { error: 'unauthorized' };
}
const { db } = getDb();
const today = todayIso();
const yesterday = previousWerktag(today);
const updated = await publishYesterdaysEntries({ db }, yesterday);
logger.info({ updated, date: yesterday }, 'published yesterday entries');
return { ok: true, updated, date: yesterday };
});
app.post('/internal/cron/recompute-streaks', async (req, reply) => {
try {
authorize(req);
} catch {
reply.code(401);
return { error: 'unauthorized' };
}
// Streaks werden lazy in `user.me` berechnet. Dieser Endpoint ist Reserve
// für künftige denormalisierte Caches (NFR-1 Performance, sobald viele User).
return { ok: true, note: 'streaks are computed lazily' };
});
app.post('/internal/cron/recompute-pflicht-minimum-5', async (req, reply) => {
try {
authorize(req);
} catch {
reply.code(401);
return { error: 'unauthorized' };
}
const { db } = getDb();
const { recomputePflichtMinimumStatusActual } = await import('../lib/pflicht-minimum.js');
const res = await recomputePflichtMinimumStatusActual(db);
logger.info(res, 'pflicht-minimum status_aktuell recomputed');
return { ok: true, ...res };
});
app.post('/internal/cron/pg-dump-backup', async (req, reply) => {
try {
authorize(req);
} catch {
reply.code(401);
return { error: 'unauthorized' };
}
// pg_dump läuft als System-Cron *im Postgres-Container* (siehe
// docs/operations.md § Backups). Dieser Endpoint ist der Audit-Trigger:
// wenn er gerufen wird, ist der Backup-Cron lebendig und der Logeintrag
// landet im API-Log (zentrale Beobachtbarkeit).
logger.info('pg-dump-backup heartbeat received');
return {
ok: true,
note: 'pg_dump runs in postgres container; see docs/operations.md',
};
});
}
+12
View File
@@ -0,0 +1,12 @@
import { createDbClient, type Db } from '@cockpit/db';
import type pg from 'pg';
import { loadEnv } from './env.js';
let cached: { db: Db; pool: pg.Pool } | undefined;
export function getDb(): { db: Db; pool: pg.Pool } {
if (cached) return cached;
const env = loadEnv();
cached = createDbClient({ connectionString: env.DATABASE_URL });
return cached;
}
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
AUTHENTIK_ISSUER: z.string().url(),
AUTHENTIK_CLIENT_ID: z.string().min(1),
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
AUTHENTIK_REDIRECT_URI: z.string().url(),
SESSION_COOKIE_NAME: z.string().default('cockpit_session'),
SESSION_TTL_HOURS: z.coerce.number().int().min(1).default(24),
SESSION_COOKIE_SECRET: z.string().min(32),
CRON_BEARER_TOKEN: z.string().min(8),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:5173'),
});
export type Env = z.infer<typeof envSchema>;
let cached: Env | undefined;
export function loadEnv(): Env {
if (cached) return cached;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
.join('\n');
throw new Error(`Ungültige Umgebung:\n${issues}`);
}
cached = parsed.data;
return cached;
}
+39
View File
@@ -0,0 +1,39 @@
// Ampel-Aggregations-Algorithmus (FR-1a).
export type Ampel = 'green' | 'amber' | 'red';
export type AreaAmpel = Ampel | 'grey';
export interface ProjectStatusLike {
statusCache: Ampel | null;
pflichtMinimumState: Record<string, boolean>;
archiviert: boolean;
}
export function aggregateAreaAmpel(projects: ProjectStatusLike[]): {
ampel: AreaAmpel;
counts: { green: number; amber: number; red: number; grey: number };
} {
const active = projects.filter((p) => !p.archiviert);
if (active.length === 0)
return { ampel: 'grey', counts: { green: 0, amber: 0, red: 0, grey: 0 } };
let red = 0;
let amber = 0;
let green = 0;
let grey = 0;
for (const p of active) {
// Pflicht-Minimum-Verstoss hebt mindestens auf gelb.
const hasMissingPflicht = Object.entries(p.pflichtMinimumState).some(([_, v]) => v === false);
let s: Ampel | 'grey' = p.statusCache ?? 'grey';
if (s === 'green' && hasMissingPflicht) s = 'amber';
if (s === 'red') red++;
else if (s === 'amber') amber++;
else if (s === 'green') green++;
else grey++;
}
if (red > 0) return { ampel: 'red', counts: { green, amber, red, grey } };
if (amber > 0) return { ampel: 'amber', counts: { green, amber, red, grey } };
if (green > 0) return { ampel: 'green', counts: { green, amber, red, grey } };
return { ampel: 'grey', counts: { green, amber, red, grey } };
}
+6
View File
@@ -0,0 +1,6 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service: 'cockpit-api' },
});
+41
View File
@@ -0,0 +1,41 @@
// Pflicht-Minimum-Berechnung (Leitfaden §9).
// Punkte 1–4 + 6 werden manuell in der App gepflegt; Punkt 5 (Status-Ampel aktuell)
// wird hier zeitabhängig berechnet und im Cron auf alle Projekte aktualisiert.
import { eq, isNotNull, isNull, and, desc } from 'drizzle-orm';
import { project, dayEntry } from '@cockpit/db';
import { werktageBetween, todayIso } from './werktag.js';
const MAX_AGE_WERKTAGE = 7;
export async function recomputePflichtMinimumStatusActual(
db: import('@cockpit/db').Db,
): Promise<{ updated: number }> {
const projects = await db.select().from(project).where(eq(project.archiviert, false));
const today = todayIso();
let updated = 0;
for (const p of projects) {
// Letzte Reflexion mit Projektbezug? (Vereinfachung: letzter day_entry mit Ampel,
// dessen Update das Projekt-Status-Cache gesetzt hat = `status_cache` selbst.)
const lastEntry = (
await db
.select()
.from(dayEntry)
.where(and(isNotNull(dayEntry.reflectedAt)))
.orderBy(desc(dayEntry.reflectedAt))
.limit(1)
)[0];
const actual = lastEntry ? werktageBetween(lastEntry.date, today) <= MAX_AGE_WERKTAGE : false;
const newState = { ...p.pflichtMinimumState, status_aktuell: actual };
if (JSON.stringify(newState) !== JSON.stringify(p.pflichtMinimumState)) {
await db.update(project).set({ pflichtMinimumState: newState }).where(eq(project.id, p.id));
updated++;
}
}
return { updated };
}
// linter
void isNull;
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { formatIsoDate, isWerktag, nextWerktag, previousWerktag, werktageBetween } from './werktag';
describe('werktag', () => {
it('formatIsoDate liefert YYYY-MM-DD in Berlin TZ', () => {
const d = new Date(Date.UTC(2026, 4, 25, 8, 30));
expect(formatIsoDate(d)).toBe('2026-05-25');
});
it('isWerktag erkennt Mo-Fr', () => {
expect(isWerktag('2026-05-25')).toBe(true); // Mo
expect(isWerktag('2026-05-29')).toBe(true); // Fr
expect(isWerktag('2026-05-30')).toBe(false); // Sa
expect(isWerktag('2026-05-31')).toBe(false); // So
});
it('nextWerktag springt über Wochenende', () => {
expect(nextWerktag('2026-05-29')).toBe('2026-06-01'); // Fr → Mo
expect(nextWerktag('2026-05-25')).toBe('2026-05-26'); // Mo → Di
});
it('previousWerktag springt über Wochenende', () => {
expect(previousWerktag('2026-06-01')).toBe('2026-05-29'); // Mo → Fr
});
it('werktageBetween zählt korrekt', () => {
// Mo 2026-05-25 bis Mo 2026-06-01 (exkl.): 5 Werktage
expect(werktageBetween('2026-05-25', '2026-06-01')).toBe(5);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Werktag-Logik in Europe/Berlin. Single source of truth fürs Backend
// (Frontend bekommt fertige Felder).
const TIMEZONE = 'Europe/Berlin';
/**
* Gibt das aktuelle Datum (oder `now`) als `YYYY-MM-DD` in Europe/Berlin zurück.
*/
export function todayIso(now: Date = new Date()): string {
return formatIsoDate(now);
}
export function formatIsoDate(d: Date): string {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return fmt.format(d); // en-CA liefert ISO YYYY-MM-DD
}
export function parseIsoDate(s: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
throw new Error(`Ungültiges ISO-Datum: ${s}`);
}
const [y, m, d] = s.split('-').map(Number) as [number, number, number];
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); // 12:00 UTC vermeidet TZ-Drift
}
/**
* Mo-Fr = Werktag. Feiertage Phase 2.
*/
export function isWerktag(s: string): boolean {
const d = parseIsoDate(s);
const wd = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, weekday: 'short' }).format(d);
return wd !== 'Sat' && wd !== 'Sun';
}
export function nextWerktag(s: string): string {
let cur = parseIsoDate(s);
do {
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
} while (!isWerktag(formatIsoDate(cur)));
return formatIsoDate(cur);
}
export function previousWerktag(s: string): string {
let cur = parseIsoDate(s);
do {
cur = new Date(cur.getTime() - 24 * 60 * 60 * 1000);
} while (!isWerktag(formatIsoDate(cur)));
return formatIsoDate(cur);
}
/**
* Zählt Werktage zwischen zwei ISO-Daten (inkl. start, exkl. end).
*/
export function werktageBetween(startIso: string, endIso: string): number {
let cur = parseIsoDate(startIso);
const end = parseIsoDate(endIso);
let count = 0;
while (cur.getTime() < end.getTime()) {
if (isWerktag(formatIsoDate(cur))) count++;
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
}
return count;
}
+29
View File
@@ -0,0 +1,29 @@
// Root tRPC-Router. Pro Story kommen weitere Sub-Router dazu.
import { router, publicProcedure } from '../trpc.js';
import { dayEntryRouter } from './day-entry.js';
import { userRouter } from './user.js';
import { pulsRouter } from './puls.js';
import { projectRouter } from './project.js';
import { dealRouter, gtmRouter } from './deal.js';
import { campaignRouter } from './campaign.js';
import { areaRouter } from './area.js';
import { blockerRouter } from './blocker.js';
export const appRouter = router({
health: publicProcedure.query(() => ({
status: 'ok',
timestamp: new Date().toISOString(),
})),
user: userRouter,
area: areaRouter,
project: projectRouter,
deal: dealRouter,
gtm: gtmRouter,
campaign: campaignRouter,
blocker: blockerRouter,
dayEntry: dayEntryRouter,
puls: pulsRouter,
});
export type AppRouter = typeof appRouter;
+8
View File
@@ -0,0 +1,8 @@
import { area } from '@cockpit/db';
import { router, protectedProcedure } from '../trpc.js';
export const areaRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.select().from(area).orderBy(area.order);
}),
});
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { eq, isNull, desc } from 'drizzle-orm';
import { blocker, project, user } from '@cockpit/db';
import { router, protectedProcedure } from '../trpc.js';
export const blockerRouter = router({
listOpen: protectedProcedure.query(async ({ ctx }) => {
return ctx.db
.select({
blocker: blocker,
opener: user,
})
.from(blocker)
.leftJoin(user, eq(user.id, blocker.openedBy))
.where(isNull(blocker.resolvedAt))
.orderBy(desc(blocker.openedAt));
}),
byProject: protectedProcedure
.input(z.object({ projectId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(blocker)
.where(eq(blocker.projectId, input.projectId))
.orderBy(desc(blocker.openedAt));
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
areaId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
ownerUserId: z.string().uuid().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const [created] = await ctx.db
.insert(blocker)
.values({
title: input.title,
description: input.description ?? null,
areaId: input.areaId ?? null,
projectId: input.projectId ?? null,
ownerUserId: input.ownerUserId ?? null,
openedBy: ctx.user.id,
})
.returning();
return created;
}),
resolve: protectedProcedure
.input(
z.object({
blockerId: z.string().uuid(),
resolutionText: z.string().min(1).max(500),
}),
)
.mutation(async ({ ctx, input }) => {
const b = (
await ctx.db.select().from(blocker).where(eq(blocker.id, input.blockerId)).limit(1)
)[0];
if (!b) throw new TRPCError({ code: 'NOT_FOUND', message: 'Blocker nicht gefunden.' });
// Authority-Check: bei projektgebundenen Blockern nur P1; sonst Ersteller oder Verantwortlicher.
let allowed = false;
if (b.projectId) {
const p = (
await ctx.db.select().from(project).where(eq(project.id, b.projectId)).limit(1)
)[0];
if (p?.verantwortlicheUserId === ctx.user.id) allowed = true;
} else {
if (b.openedBy === ctx.user.id || b.ownerUserId === ctx.user.id) allowed = true;
}
if (!allowed) {
throw new TRPCError({
code: 'FORBIDDEN',
message:
'Nur die/der Projektverantwortliche darf diesen Blocker auflösen. (Bei projektlosen Blockern: Ersteller oder Verantwortliche/r.)',
});
}
const [updated] = await ctx.db
.update(blocker)
.set({
resolvedBy: ctx.user.id,
resolvedAt: new Date(),
resolutionText: input.resolutionText,
})
.where(eq(blocker.id, input.blockerId))
.returning();
return updated;
}),
});
+67
View File
@@ -0,0 +1,67 @@
import { desc, eq } from 'drizzle-orm';
import { campaign, leadHandover, deal, area } from '@cockpit/db';
import { CampaignSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
const MARKETING_AREA_SLUG = 'marketing';
export const campaignRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.select().from(campaign).orderBy(desc(campaign.createdAt));
}),
create: protectedProcedure
.input(CampaignSchemas.createCampaignInput)
.mutation(async ({ ctx, input }) => {
const a = (
await ctx.db.select().from(area).where(eq(area.slug, MARKETING_AREA_SLUG)).limit(1)
)[0];
if (!a) throw new Error('Marketing-Area fehlt — bitte seed laufen lassen.');
const [created] = await ctx.db
.insert(campaign)
.values({
areaId: a.id,
name: input.name,
status: input.status,
reach: input.reach,
ownerUserId: input.ownerUserId ?? null,
})
.returning();
return created;
}),
funnel: protectedProcedure.query(async ({ ctx }) => {
// Lead-Funnel: simple Aggregation der bekannten Quellen.
const campaigns = await ctx.db.select().from(campaign);
const reichweite = campaigns.reduce((acc, c) => acc + c.reach, 0);
const handovers = await ctx.db.select().from(leadHandover);
const leads = handovers.length;
const deals = await ctx.db.select().from(deal);
const mql = deals.filter((d) => ['erstgespraech', 'demo', 'angebot'].includes(d.stage)).length;
const uebergabe = handovers.length;
// Frontend kann die Stage-Übergänge daraus rendern.
return {
reichweite,
website: Math.round(reichweite * 0.2), // Placeholder bis ein Tracking-Feed existiert.
leads,
mql,
uebergabe,
};
}),
crosslink: protectedProcedure.query(async ({ ctx }) => {
const handovers = await ctx.db.select().from(leadHandover);
const dealsFromHandovers = handovers.filter((h) => h.dealId !== null).length;
// Won-Volumen aus Handover-bezogenen Deals dieses Quartals.
const dealRows = await ctx.db.select().from(deal);
const wonValue = dealRows
.filter((d) => d.wonAt)
.reduce((acc, d) => acc + (d.wonValueEur ?? 0), 0);
return {
uebergebenSeitTagen: 30,
leadsUebergeben: handovers.length,
dealsErzeugt: dealsFromHandovers,
gewonneneEurQuartal: wonValue,
};
}),
});
+335
View File
@@ -0,0 +1,335 @@
import { TRPCError } from '@trpc/server';
import { and, eq, isNull, isNotNull, lt, asc, inArray } from 'drizzle-orm';
import { dayEntry, todo, blocker, type Todo } from '@cockpit/db';
import { DayEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso } from '../lib/werktag.js';
export const dayEntryRouter = router({
/**
* Heutige ToDos aller aktiven Mitarbeiter, gruppiert pro User.
* Pro PRD-Änderung 2026-05-25: Heutige ToDos sind teamweit sichtbar
* (ersetzt die ältere private-vor-Publikation-Regel).
*/
todayTeam: protectedProcedure.query(async ({ ctx }) => {
const date = todayIso();
const entries = await ctx.db
.select()
.from(dayEntry)
.where(eq(dayEntry.date, date));
if (entries.length === 0) return [] as Array<{ userId: string; entryStatus: string; todos: Todo[] }>;
const ids = entries.map((e) => e.id);
const todos = await ctx.db
.select()
.from(todo)
.where(inArray(todo.dayEntryId, ids))
.orderBy(asc(todo.order));
const todosByEntry = new Map<string, Todo[]>();
for (const t of todos) {
const arr = todosByEntry.get(t.dayEntryId) ?? [];
arr.push(t);
todosByEntry.set(t.dayEntryId, arr);
}
return entries.map((e) => ({
userId: e.userId,
entryStatus: e.status,
todos: todosByEntry.get(e.id) ?? [],
}));
}),
/**
* Offene ToDos der vergangenen Tage, die noch nicht übertragen wurden.
* Werden auf der Heute-Seite vor dem Einstempeln eingeblendet (siehe FR-22 Erweiterung).
*/
pendingCarryOver: protectedProcedure.query(async ({ ctx }) => {
return ctx.db
.select({
id: todo.id,
title: todo.title,
bezugKind: todo.bezugKind,
bezugId: todo.bezugId,
fromDate: todo.date,
})
.from(todo)
.where(
and(
eq(todo.userId, ctx.user.id),
eq(todo.status, 'open'),
eq(todo.carriedOver, false),
lt(todo.date, todayIso()),
),
)
.orderBy(asc(todo.date), asc(todo.order))
.limit(20);
}),
/**
* Heute-Status der eigenen Person.
*/
today: protectedProcedure.query(async ({ ctx }) => {
const date = todayIso();
const rows = await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
.limit(1);
const entry = rows[0] ?? null;
if (!entry) {
return { date, entry: null, todos: [] as Todo[] };
}
const todos = await ctx.db
.select()
.from(todo)
.where(eq(todo.dayEntryId, entry.id))
.orderBy(todo.order);
return { date, entry, todos };
}),
/**
* Morgen-Einstempeln + ToDo-Liste setzen.
*/
checkIn: protectedProcedure
.input(DayEntrySchemas.checkInInput)
.mutation(async ({ ctx, input }) => {
const date = input.date ?? todayIso();
// Verhindert Doppel-Einstempeln. Wenn bereits Einträge existieren, Fehler.
const existing = await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
.limit(1);
if (existing[0]) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Du bist heute schon locked in.',
});
}
const result = await ctx.db.transaction(async (tx) => {
const [created] = await tx
.insert(dayEntry)
.values({
userId: ctx.user.id,
date,
status: 'begun',
checkedInAt: new Date(),
})
.returning();
if (!created) throw new Error('DayEntry konnte nicht angelegt werden.');
const todoRows = await tx
.insert(todo)
.values(
input.todos.map((t, idx) => ({
dayEntryId: created.id,
userId: ctx.user.id,
date,
title: t.title,
order: idx,
bezugKind: t.bezugKind ?? null,
bezugId: t.bezugId ?? null,
})),
)
.returning();
// Alle alten offenen ToDos der Person als "übertragen" markieren.
// Egal, ob sie in der heutigen Liste landeten oder nicht — der User hat
// beim Einstempeln über sie entschieden.
const stale = await tx
.select({ id: todo.id })
.from(todo)
.where(
and(
eq(todo.userId, ctx.user.id),
eq(todo.status, 'open'),
eq(todo.carriedOver, false),
lt(todo.date, date),
),
);
if (stale.length > 0) {
await tx
.update(todo)
.set({ carriedOver: true })
.where(
inArray(
todo.id,
stale.map((s) => s.id),
),
);
}
return { entry: created, todos: todoRows, carriedOver: stale.length };
});
return result;
}),
/**
* ToDos im Verlauf des Tages ändern (vor Reflexion).
*/
updateTodos: protectedProcedure
.input(DayEntrySchemas.todoMutationInput)
.mutation(async ({ ctx, input }) => {
const entries = await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id)))
.limit(1);
const entry = entries[0];
if (!entry) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
}
if (entry.status === 'full') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Du hast schon reflektiert. ToDos sind eingefroren.',
});
}
const result = await ctx.db.transaction(async (tx) => {
await tx.delete(todo).where(eq(todo.dayEntryId, entry.id));
if (input.todos.length === 0) return { todos: [] as Todo[] };
const newTodos = await tx
.insert(todo)
.values(
input.todos.map((t, idx) => ({
dayEntryId: entry.id,
userId: ctx.user.id,
date: entry.date,
title: t.title,
order: idx,
bezugKind: t.bezugKind ?? null,
bezugId: t.bezugId ?? null,
})),
)
.returning();
return { todos: newTodos };
});
return result;
}),
/**
* Abend-Reflexion. Setzt Ampel + Trend, optional Blocker.
*/
submitReflection: protectedProcedure
.input(DayEntrySchemas.submitReflectionInput)
.mutation(async ({ ctx, input }) => {
const entries = await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id)))
.limit(1);
const entry = entries[0];
if (!entry) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
}
if (entry.status === 'full' && entry.editLockedAt && entry.editLockedAt < new Date()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Die Reflexion ist nicht mehr editierbar.',
});
}
// Pflicht-Trend bei Bezug=Projekt (Story 2.4)
const todoRows = await ctx.db.select().from(todo).where(eq(todo.dayEntryId, entry.id));
const hasProjectBezug = todoRows.some((t) => t.bezugKind === 'project');
if (hasProjectBezug && !input.trend) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Bei Projektbezug ist der Trend Pflicht.',
});
}
const result = await ctx.db.transaction(async (tx) => {
// ToDo-Statuswechsel.
for (const t of input.todos) {
await tx
.update(todo)
.set({
status: t.status,
dropReason: t.dropReason ?? null,
completedAt: t.status === 'done' ? new Date() : null,
droppedAt: t.status === 'dropped' ? new Date() : null,
})
.where(and(eq(todo.id, t.id), eq(todo.dayEntryId, entry.id)));
}
let blockerRecord: { id: string } | null = null;
if (input.blocker) {
const [b] = await tx
.insert(blocker)
.values({
title: input.blocker.title,
description: input.blocker.description ?? null,
openedBy: ctx.user.id,
})
.returning({ id: blocker.id });
blockerRecord = b ?? null;
}
const now = new Date();
const [updated] = await tx
.update(dayEntry)
.set({
status: 'full',
reflectedAt: now,
reflectionText: input.reflectionText,
ampel: input.ampel,
trend: input.trend ?? null,
url: input.url ?? null,
blockerId: blockerRecord?.id ?? entry.blockerId,
editLockedAt: new Date(now.getTime() + 30 * 60 * 1000),
updatedAt: now,
})
.where(eq(dayEntry.id, entry.id))
.returning();
if (!updated) throw new Error('Update fehlgeschlagen.');
return { entry: updated, blocker: blockerRecord };
});
return result;
}),
/**
* Konsolidierter Vortrags-Feed: alle bereits publizierten Einträge.
* Nutzbar in Story 3.4 (Puls-Feed).
*/
feed: protectedProcedure.query(async ({ ctx }) => {
const entries = await ctx.db
.select()
.from(dayEntry)
.where(isNotNull(dayEntry.publishedAt))
.orderBy(dayEntry.publishedAt)
.limit(50);
return entries;
}),
/**
* „Heute fehlt"-Liste (Anwesenheits-Übersicht).
*/
todayPresence: protectedProcedure.query(async ({ ctx }) => {
const date = todayIso();
const rows = await ctx.db
.select({
userId: dayEntry.userId,
status: dayEntry.status,
})
.from(dayEntry)
.where(eq(dayEntry.date, date));
return rows;
}),
});
// Helper für Cron-Endpoint
export async function publishYesterdaysEntries(
ctx: { db: import('@cockpit/db').Db },
yesterdayIso: string,
): Promise<number> {
const now = new Date();
const result = await ctx.db
.update(dayEntry)
.set({ publishedAt: now })
.where(and(eq(dayEntry.date, yesterdayIso), isNull(dayEntry.publishedAt)));
return result.rowCount ?? 0;
}
+136
View File
@@ -0,0 +1,136 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { desc, eq, sql, sum } from 'drizzle-orm';
import { deal, gtmStep, area } from '@cockpit/db';
import { DealSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
const SALES_AREA_SLUG = 'vertrieb';
async function getSalesAreaId(db: import('@cockpit/db').Db): Promise<string> {
const a = (await db.select().from(area).where(eq(area.slug, SALES_AREA_SLUG)).limit(1))[0];
if (!a) throw new Error('Sales-Area fehlt — bitte seed laufen lassen.');
return a.id;
}
export const dealRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.select().from(deal).orderBy(desc(deal.enteredStageAt));
}),
kpis: protectedProcedure.query(async ({ ctx }) => {
const allDeals = await ctx.db.select().from(deal);
const open = allDeals.filter((d) => !d.wonAt);
const won = allDeals.filter((d) => d.wonAt);
const weightedPipeline = open.reduce((acc, d) => acc + (d.valueEur * d.probability) / 100, 0);
// „Laufendes Quartal" = aktuelles Kalenderquartal (Server-Zeit).
const now = new Date();
const qStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
const wonThisQuarter = won.filter((d) => d.wonAt && d.wonAt >= qStart);
const wonValueQuarter = wonThisQuarter.reduce((acc, d) => acc + (d.wonValueEur ?? 0), 0);
return {
weightedPipelineEur: Math.round(weightedPipeline),
openDealCount: open.length,
wonCountQuarter: wonThisQuarter.length,
wonValueQuarterEur: wonValueQuarter,
};
}),
create: protectedProcedure.input(DealSchemas.createDealInput).mutation(async ({ ctx, input }) => {
const areaId = await getSalesAreaId(ctx.db);
const [created] = await ctx.db
.insert(deal)
.values({
areaId,
name: input.name,
ownerUserId: input.ownerUserId ?? null,
valueEur: input.valueEur,
probability: input.probability,
stage: input.stage,
})
.returning();
return created;
}),
updateStage: protectedProcedure
.input(DealSchemas.updateDealStageInput)
.mutation(async ({ ctx, input }) => {
const [updated] = await ctx.db
.update(deal)
.set({
stage: input.stage,
enteredStageAt: new Date(),
wonAt: input.stage === 'abschluss' ? new Date() : null,
wonValueEur:
input.stage === 'abschluss'
? sql`COALESCE(${deal.valueEur} * ${deal.probability} / 100, 0)`
: null,
})
.where(eq(deal.id, input.dealId))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Deal nicht gefunden.' });
return updated;
}),
});
export const gtmRouter = router({
list: protectedProcedure
.input(z.object({ product: z.string().optional() }))
.query(async ({ ctx, input }) => {
if (input.product) {
return ctx.db
.select()
.from(gtmStep)
.where(eq(gtmStep.product, input.product))
.orderBy(gtmStep.order);
}
return ctx.db.select().from(gtmStep).orderBy(gtmStep.product, gtmStep.order);
}),
create: protectedProcedure
.input(
z.object({
product: z.string().min(1).max(100),
title: z.string().min(1).max(200),
ownerUserId: z.string().uuid().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db
.select()
.from(gtmStep)
.where(eq(gtmStep.product, input.product));
const [created] = await ctx.db
.insert(gtmStep)
.values({
product: input.product,
title: input.title,
ownerUserId: input.ownerUserId ?? null,
order: existing.length,
})
.returning();
return created;
}),
setStatus: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
status: z.enum(['open', 'in_progress', 'done']),
}),
)
.mutation(async ({ ctx, input }) => {
const [updated] = await ctx.db
.update(gtmStep)
.set({ status: input.status })
.where(eq(gtmStep.id, input.id))
.returning();
return updated;
}),
});
// linter
void sum;
+148
View File
@@ -0,0 +1,148 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { and, eq, desc } from 'drizzle-orm';
import { project, milestone, area, user, type Project } from '@cockpit/db';
import { ProjectSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
const byAreaInput = z.object({
slug: z.string().min(1),
includeArchived: z.boolean().optional(),
});
const byIdInput = z.object({ id: z.string().uuid() });
export const projectRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
project: project,
area: area,
verantwortliche: user,
})
.from(project)
.innerJoin(area, eq(project.areaId, area.id))
.leftJoin(user, eq(user.id, project.verantwortlicheUserId))
.where(eq(project.archiviert, false));
return rows;
}),
byAreaSlug: protectedProcedure.input(byAreaInput).query(async ({ ctx, input }) => {
const a = (await ctx.db.select().from(area).where(eq(area.slug, input.slug)).limit(1))[0];
if (!a) return [];
const projects = await ctx.db
.select()
.from(project)
.where(
input.includeArchived
? eq(project.areaId, a.id)
: and(eq(project.areaId, a.id), eq(project.archiviert, false)),
)
.orderBy(desc(project.createdAt));
return projects;
}),
byId: protectedProcedure.input(byIdInput).query(async ({ ctx, input }) => {
const rows = await ctx.db
.select({ project: project, area: area })
.from(project)
.innerJoin(area, eq(area.id, project.areaId))
.where(eq(project.id, input.id))
.limit(1);
const row = rows[0];
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
const ms = await ctx.db.select().from(milestone).where(eq(milestone.projectId, row.project.id));
return { project: row.project, area: row.area, milestones: ms };
}),
create: protectedProcedure
.input(ProjectSchemas.createProjectInput)
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.transaction(async (tx) => {
const [created] = await tx
.insert(project)
.values({
areaId: input.areaId,
name: input.name,
kind: input.kind,
verantwortlicheUserId: input.verantwortlicheUserId ?? null,
arbeitsmodus: input.arbeitsmodus ?? null,
outlineSteckbriefUrl: input.outlineSteckbriefUrl,
planeProjectUrl: input.planeProjectUrl,
risikolisteUrl: input.risikolisteUrl ?? null,
dodText: input.dodText ?? null,
start: input.start ?? null,
deadline: input.deadline ?? null,
createdBy: ctx.user.id,
pflichtMinimumState: {
steckbrief_freigegeben: false,
verantwortlicher: !!input.verantwortlicheUserId,
plane_board: true,
meilensteine: input.milestones.length > 0,
status_aktuell: false,
retrospektive: false,
},
})
.returning();
if (!created) throw new Error('Projekt konnte nicht angelegt werden.');
if (input.milestones.length > 0) {
await tx.insert(milestone).values(
input.milestones.map((m, idx) => ({
projectId: created.id,
title: m.title,
due: m.due ?? null,
order: idx,
})),
);
}
return created;
});
return result;
}),
archive: protectedProcedure
.input(ProjectSchemas.archiveProjectInput)
.mutation(async ({ ctx, input }) => {
const p = (
await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1)
)[0];
if (!p) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
if (p.verantwortlicheUserId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Nur die/der Projektverantwortliche darf das Projekt abschließen.',
});
}
const [updated] = await ctx.db
.update(project)
.set({
archiviert: true,
archiviertAm: new Date(),
lessonsLearnedUrl: input.lessonsLearnedUrl,
pflichtMinimumState: { ...p.pflichtMinimumState, retrospektive: true },
})
.where(eq(project.id, input.projectId))
.returning();
return updated as Project;
}),
setMilestoneStatus: protectedProcedure
.input(ProjectSchemas.setMilestoneStatusInput)
.mutation(async ({ ctx, input }) => {
const [updated] = await ctx.db
.update(milestone)
.set({ status: input.status })
.where(eq(milestone.id, input.milestoneId))
.returning();
return updated;
}),
listArchived: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db
.select()
.from(project)
.where(eq(project.archiviert, true))
.orderBy(desc(project.archiviertAm));
}),
});
+97
View File
@@ -0,0 +1,97 @@
import { and, eq, isNotNull, isNull, count } from 'drizzle-orm';
import { area, project, blocker, dayEntry, user, type Area, type Project } from '@cockpit/db';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso, previousWerktag } from '../lib/werktag.js';
import { aggregateAreaAmpel } from '../lib/ampel.js';
export const pulsRouter = router({
overview: protectedProcedure.query(async ({ ctx }) => {
const todayDate = todayIso();
const [areas, allProjects, openBlockers, todayPresence, totalActiveUsers, feedRows] =
await Promise.all([
ctx.db.select().from(area).orderBy(area.order),
ctx.db.select().from(project).where(eq(project.archiviert, false)),
ctx.db.select().from(blocker).where(isNull(blocker.resolvedAt)),
ctx.db
.select({ userId: dayEntry.userId, status: dayEntry.status })
.from(dayEntry)
.where(eq(dayEntry.date, todayDate)),
ctx.db.select({ c: count() }).from(user).where(eq(user.active, true)),
ctx.db
.select({
id: dayEntry.id,
userId: dayEntry.userId,
userName: user.name,
date: dayEntry.date,
ampel: dayEntry.ampel,
trend: dayEntry.trend,
reflectionText: dayEntry.reflectionText,
publishedAt: dayEntry.publishedAt,
})
.from(dayEntry)
.innerJoin(user, eq(user.id, dayEntry.userId))
.where(and(isNotNull(dayEntry.publishedAt), eq(dayEntry.status, 'full')))
.orderBy(dayEntry.publishedAt)
.limit(50),
]);
const projectsByArea = new Map<string, Project[]>();
for (const p of allProjects) {
const list = projectsByArea.get(p.areaId) ?? [];
list.push(p);
projectsByArea.set(p.areaId, list);
}
const areaSummaries = areas.map((a: Area) => {
const projs = projectsByArea.get(a.id) ?? [];
const agg = aggregateAreaAmpel(projs);
return {
id: a.id,
slug: a.slug,
name: a.name,
kind: a.kind,
ampel: agg.ampel,
counts: agg.counts,
};
});
const presentToday = new Set(todayPresence.map((p) => p.userId));
const usersAll = await ctx.db
.select({ id: user.id, name: user.name })
.from(user)
.where(eq(user.active, true));
const fehlende = usersAll.filter((u) => !presentToday.has(u.id));
return {
date: todayDate,
areas: areaSummaries,
blockers: openBlockers.map((b) => ({
id: b.id,
title: b.title,
openedAt: b.openedAt,
areaId: b.areaId,
projectId: b.projectId,
})),
heuteQuote: {
gepingt: presentToday.size,
gesamt: totalActiveUsers[0]?.c ?? 0,
fehlende,
},
jourfixe: { nextDate: nextJourfixe(todayDate), label: 'Di 11:00' },
pflichtMinimumOffen: allProjects
.filter((p) => Object.values(p.pflichtMinimumState).some((v) => v === false))
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId })),
feed: feedRows,
gestern: previousWerktag(todayDate),
};
}),
});
function nextJourfixe(todayIsoDate: string): string {
// Nächster Dienstag (Default Jourfixe). Wenn heute Dienstag, dann heute.
const d = new Date(`${todayIsoDate}T00:00:00Z`);
const wd = d.getUTCDay(); // 0=So..6=Sa
const offset = (2 - wd + 7) % 7 || 0; // 2=Di
d.setUTCDate(d.getUTCDate() + offset);
return d.toISOString().slice(0, 10);
}
+111
View File
@@ -0,0 +1,111 @@
import { and, eq, gte, lte } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
import { user, dayEntry, absenceDay } from '@cockpit/db';
import { DayEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import {
todayIso,
isWerktag,
previousWerktag,
parseIsoDate,
formatIsoDate,
} from '../lib/werktag.js';
async function computeStreak(db: import('@cockpit/db').Db, userId: string): Promise<number> {
const today = todayIso();
// Hole alle relevanten DayEntries und Abwesenheiten der letzten 90 Tage.
const endIso = today;
const start = parseIsoDate(today);
start.setUTCDate(start.getUTCDate() - 120);
const startIso = formatIsoDate(start);
const [entries, absences] = await Promise.all([
db
.select({ date: dayEntry.date, status: dayEntry.status })
.from(dayEntry)
.where(
and(eq(dayEntry.userId, userId), gte(dayEntry.date, startIso), lte(dayEntry.date, endIso)),
),
db
.select({ date: absenceDay.date })
.from(absenceDay)
.where(
and(
eq(absenceDay.userId, userId),
gte(absenceDay.date, startIso),
lte(absenceDay.date, endIso),
),
),
]);
const fullByDate = new Map<string, boolean>();
for (const e of entries) fullByDate.set(e.date, e.status === 'full');
const absSet = new Set(absences.map((a) => a.date));
// Zähle aufeinanderfolgende volle Werktage rückwärts ab heute.
let cursor = today;
let streak = 0;
// Wenn heute noch nicht voll: starten beim vorherigen Werktag.
if (!fullByDate.get(today)) {
cursor = previousWerktag(today);
}
while (true) {
if (!isWerktag(cursor)) break;
if (absSet.has(cursor)) {
cursor = previousWerktag(cursor);
continue;
}
if (fullByDate.get(cursor)) {
streak++;
cursor = previousWerktag(cursor);
continue;
}
break;
}
return streak;
}
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
const streak = await computeStreak(ctx.db, ctx.user.id);
return {
id: ctx.user.id,
name: ctx.user.name,
email: ctx.user.email,
streak,
};
}),
list: protectedProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.active, true));
return rows;
}),
markAbsent: protectedProcedure
.input(DayEntrySchemas.markAbsentInput)
.mutation(async ({ ctx, input }) => {
if (input.to < input.from) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Endedatum vor Anfangsdatum.' });
}
// Enumeriere Werktage zwischen from und to (inkl.) und füge sie als absence_day ein.
let cursor = input.from;
const toInsert: { userId: string; date: string; reason: 'urlaub' | 'krank' | 'sonstiges' }[] =
[];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (cursor <= input.to) {
if (isWerktag(cursor)) {
toInsert.push({ userId: ctx.user.id, date: cursor, reason: input.reason });
}
const next = parseIsoDate(cursor);
next.setUTCDate(next.getUTCDate() + 1);
cursor = formatIsoDate(next);
}
if (toInsert.length === 0) return { inserted: 0 };
await ctx.db.insert(absenceDay).values(toInsert).onConflictDoNothing();
return { inserted: toInsert.length };
}),
});
+91
View File
@@ -0,0 +1,91 @@
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { loadEnv } from './env.js';
import { logger } from './lib/logger.js';
import { registerAuthMiddleware } from './auth/middleware.js';
import { registerAuthRoutes } from './auth/routes.js';
import { registerCronRoutes } from './cron/routes.js';
import { getDb } from './db.js';
import { appRouter, type AppRouter } from './routers/_app.js';
import { createContext } from './trpc.js';
async function main() {
const env = loadEnv();
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
base: { service: 'cockpit-api' },
},
});
await app.register(cookie, {
secret: env.SESSION_COOKIE_SECRET,
});
await registerAuthMiddleware(app);
await registerAuthRoutes(app);
await registerCronRoutes(app);
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext,
onError({ path, error }) {
logger.error({ path, err: error }, 'trpc error');
},
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
});
app.get('/api/health', async () => {
const { pool } = getDb();
let dbOk = false;
try {
await pool.query('SELECT 1');
dbOk = true;
} catch (err) {
logger.warn({ err }, 'db ping failed');
}
let authentikOk = false;
try {
const res = await fetch(new URL('.well-known/openid-configuration', env.AUTHENTIK_ISSUER), {
signal: AbortSignal.timeout(3_000),
});
authentikOk = res.ok;
} catch (err) {
logger.warn({ err }, 'authentik ping failed');
}
const status = dbOk && authentikOk ? 'ok' : 'degraded';
return {
status,
db: dbOk ? 'ok' : 'down',
authentik: authentikOk ? 'ok' : 'down',
timestamp: new Date().toISOString(),
};
});
app.get('/api/me', async (req, reply) => {
if (!req.cockpitUser) {
reply.code(401);
return { error: 'Nicht angemeldet.' };
}
return {
id: req.cockpitUser.id,
name: req.cockpitUser.name,
email: req.cockpitUser.email,
};
});
try {
await app.listen({ port: env.PORT, host: '0.0.0.0' });
logger.info({ port: env.PORT }, 'api server started');
} catch (err) {
logger.error(err, 'failed to start api server');
process.exit(1);
}
}
main();
+69
View File
@@ -0,0 +1,69 @@
// tRPC v11 Core: Context, initTRPC, Procedure-Typen, Audit-Wrapper.
import './types.js';
import { initTRPC, TRPCError } from '@trpc/server';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { User } from '@cockpit/db';
import { getDb } from './db.js';
export interface Context {
user: User | null;
sessionId: string | null;
req: FastifyRequest;
reply: FastifyReply;
db: ReturnType<typeof getDb>['db'];
}
export async function createContext(opts: {
req: FastifyRequest;
res: FastifyReply;
}): Promise<Context> {
const { db } = getDb();
return {
user: opts.req.cockpitUser,
sessionId: opts.req.cockpitSessionId,
req: opts.req,
reply: opts.res,
db,
};
}
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const mergeRouters = t.mergeRouters;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Du bist nicht angemeldet.',
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Audit-Wrapper: schreibt nach erfolgreicher Mutation einen audit_event.
// Genutzt als zusätzliche Middleware auf Mutations.
export function withAudit<TInput, TResult extends Record<string, unknown> | unknown>(
action: string,
) {
return async function audit(opts: {
ctx: Context;
input: TInput;
next: () => Promise<{ ok: true; data: TResult } | TResult>;
}) {
const result = await opts.next();
if (!opts.ctx.user) return result;
const { auditEvent } = await import('@cockpit/db');
await opts.ctx.db.insert(auditEvent).values({
actorUserId: opts.ctx.user.id,
action,
entityKind: action.split('.')[0] ?? 'unknown',
payload: { input: opts.input as unknown },
});
return result;
};
}
+13
View File
@@ -0,0 +1,13 @@
// Globale Fastify-Augmentations — wird von jeder Datei mitgezogen, die
// `apps/api/src/server.ts` oder via Re-Export-Pfad in Reach kommt.
import type { User } from '@cockpit/db';
declare module 'fastify' {
interface FastifyRequest {
cockpitUser: User | null;
cockpitSessionId: string | null;
}
}
export {};