docs(crm): update Summarize.md with deployment status and test results

All CRM endpoints tested successfully on insight-dev-01.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 18:08:00 +01:00
parent 525fe006e9
commit 43877bbb4a
6 changed files with 291 additions and 12 deletions

View file

@ -59,8 +59,9 @@ packages/crm-service/
- `docker-compose.crm.yml` im Projekt-Root
- Port: 3100
- Netzwerke: insight-web, insight-db, insight-cache
- Traefik-Route: /api/v1/crm/*
- JWT Public Key als Read-Only Volume
- Traefik-Route: `Host(172.20.10.59) && PathPrefix(/api/v1/crm)` mit Priority 100
- JWT Public Key als Read-Only Volume (.keys/jwt-public.pem)
- Direkte PostgreSQL-Verbindung (PgBouncer unterstuetzt kein search_path fuer Schema-Auswahl)
### Sicherheit
@ -70,10 +71,44 @@ packages/crm-service/
- TenantGuard sichert mandantenbezogenen Zugriff
- Globaler ValidationPipe (whitelist + forbidNonWhitelisted)
- Strict TypeScript, kein `any`
- 401 bei fehlendem/ungueltigem Token
### Deployment-Status
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
- Container: insight-crm (Development-Mode)
- Prisma Migration `20260310163211_init` angewendet
- Alle API-Endpunkte getestet und funktionsfaehig
- Traefik-Routing aktiv: http://172.20.10.59/api/v1/crm/*
- Swagger-Docs: nicht ueber Traefik erreichbar (nur Container-intern)
### Getestete Endpunkte
| Test | Ergebnis |
|------|----------|
| GET /contacts (leere Liste) | 200 OK, pagination korrekt |
| POST /contacts (Kontakt erstellen) | 201 Created, UUID generiert |
| GET /contacts/:id | 200 OK, Detail korrekt |
| PATCH /contacts/:id | 200 OK, Update + Tags |
| GET /contacts?search=Muster | 200 OK, Suche funktioniert |
| POST /activities (Notiz) | 201 Created, contactId verknuepft |
| POST /pipelines (mit 4 Stages) | 201 Created, Stages korrekt |
| POST /deals | 201 Created, Pipeline/Stage/Contact verknuepft |
| PATCH /deals/:id (WON) | 200 OK, closedAt automatisch gesetzt |
| GET /deals (Liste) | 200 OK, pagination korrekt |
| GET /contacts ohne Token | 401 Unauthorized |
| Validierung (falsche Felder) | 400 Bad Request, Details korrekt |
### Bekannte Einschraenkungen
- PgBouncer kann nicht genutzt werden (search_path nicht kompatibel mit transaction pooling)
- Swagger-Docs nur Container-intern erreichbar (kein Traefik-Route fuer /api/v1/crm/docs)
### Naechste Schritte
1. `npm install` in packages/crm-service/
2. Prisma Migration: `npx prisma migrate dev --schema=prisma/crm.schema.prisma --name init`
3. Docker Build testen
4. Integration mit laufender Plattform testen
1. DELETE-Endpunkte testen (Kontakte, Deals, Pipelines)
2. Swagger-Docs ueber Traefik erreichbar machen (optional)
3. Integration mit Frontend (CRM-Modul im Admin-Bereich)
4. E2E-Tests schreiben
5. Production-Build testen (multi-stage Dockerfile)

View file

@ -0,0 +1,51 @@
/* ============================================================
WeatherWidget - Kompakte Wetter-Anzeige
============================================================ */
.weather {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
font-size: 0.875rem;
color: var(--color-text);
white-space: nowrap;
}
.weatherIcon {
font-size: 1.25rem;
line-height: 1;
}
.weatherTemp {
font-weight: 600;
color: var(--color-text);
}
.weatherLabel {
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.weatherCity {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.hint {
font-size: 0.8125rem;
color: var(--color-text-muted);
text-decoration: none;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
transition: color 0.15s;
}
.hint:hover {
color: var(--color-primary);
text-decoration: underline;
}

View file

@ -0,0 +1,45 @@
import { Link } from 'react-router-dom';
import { useWeather } from '../hooks/useWeather';
import styles from './WeatherWidget.module.css';
interface WeatherWidgetProps {
city: string | null | undefined;
}
export function WeatherWidget({ city }: WeatherWidgetProps) {
const { data, isLoading, isError } = useWeather(city);
// Kein Ort im Profil -> Hinweis-Link
if (!city || city.trim().length < 2) {
return (
<Link to="/profile" className={styles.hint}>
Ort im Profil hinterlegen
</Link>
);
}
// Laden
if (isLoading) {
return (
<div className={styles.weather}>
<span className={styles.weatherLabel}>Wetter wird geladen...</span>
</div>
);
}
// Fehler -> nichts anzeigen (graceful degradation)
if (isError || !data) {
return null;
}
return (
<div className={styles.weather}>
<span className={styles.weatherIcon}>{data.icon}</span>
<span className={styles.weatherTemp}>
{Math.round(data.temperature)}&deg;C
</span>
<span className={styles.weatherLabel}>{data.label}</span>
<span className={styles.weatherCity}>{data.cityName}</span>
</div>
);
}

View file

@ -0,0 +1,126 @@
import { useQuery } from '@tanstack/react-query';
// ============================================================
// Open-Meteo API Types
// ============================================================
interface GeocodingResponse {
results?: Array<{
latitude: number;
longitude: number;
name: string;
country: string;
}>;
}
interface WeatherResponse {
current: {
temperature_2m: number;
weather_code: number;
is_day: number;
};
}
// ============================================================
// Public Types
// ============================================================
export interface WeatherData {
temperature: number;
weatherCode: number;
isDay: boolean;
cityName: string;
icon: string;
label: string;
}
// ============================================================
// WMO Weather Code Mapping (deutsch)
// ============================================================
export function getWeatherInfo(
code: number,
isDay: boolean,
): { icon: string; label: string } {
if (code === 0)
return { icon: isDay ? '\u2600\uFE0F' : '\uD83C\uDF19', label: 'Klar' };
if (code <= 3)
return {
icon: isDay ? '\u26C5' : '\uD83C\uDF19',
label: 'Teilweise bew\u00F6lkt',
};
if (code <= 49)
return { icon: '\u2601\uFE0F', label: 'Bew\u00F6lkt' };
if (code <= 59)
return { icon: '\uD83C\uDF27\uFE0F', label: 'Nieselregen' };
if (code <= 69)
return { icon: '\uD83C\uDF27\uFE0F', label: 'Regen' };
if (code <= 79)
return { icon: '\uD83C\uDF28\uFE0F', label: 'Schnee' };
if (code <= 84)
return { icon: '\uD83C\uDF27\uFE0F', label: 'Regenschauer' };
if (code <= 94)
return { icon: '\uD83C\uDF28\uFE0F', label: 'Schneeschauer' };
if (code <= 99)
return { icon: '\u26C8\uFE0F', label: 'Gewitter' };
return { icon: '\u2601\uFE0F', label: 'Bew\u00F6lkt' };
}
// ============================================================
// Hook
// ============================================================
export function useWeather(city: string | null | undefined) {
// Schritt 1: City -> Koordinaten (Geocoding)
const geocoding = useQuery<GeocodingResponse>({
queryKey: ['geocoding', city],
queryFn: async () => {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city!)}&count=1&language=de&format=json`;
const res = await fetch(url);
if (!res.ok) throw new Error('Geocoding fehlgeschlagen');
return res.json() as Promise<GeocodingResponse>;
},
enabled: !!city && city.trim().length >= 2,
staleTime: 24 * 60 * 60 * 1000, // 24 Stunden
retry: 1,
});
const geoResult = geocoding.data?.results?.[0];
// Schritt 2: Koordinaten -> Aktuelles Wetter
const weather = useQuery<WeatherResponse>({
queryKey: ['weather', geoResult?.latitude, geoResult?.longitude],
queryFn: async () => {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${geoResult!.latitude}&longitude=${geoResult!.longitude}&current=temperature_2m,weather_code,is_day&timezone=auto`;
const res = await fetch(url);
if (!res.ok) throw new Error('Wetter-Abfrage fehlgeschlagen');
return res.json() as Promise<WeatherResponse>;
},
enabled: !!geoResult,
staleTime: 15 * 60 * 1000, // 15 Minuten
retry: 1,
});
// Ergebnis zusammenbauen
const data: WeatherData | undefined =
weather.data && geoResult
? (() => {
const { temperature_2m, weather_code, is_day } = weather.data.current;
const info = getWeatherInfo(weather_code, is_day === 1);
return {
temperature: temperature_2m,
weatherCode: weather_code,
isDay: is_day === 1,
cityName: geoResult.name,
icon: info.icon,
label: info.label,
};
})()
: undefined;
return {
data,
isLoading: geocoding.isLoading || weather.isLoading,
isError: geocoding.isError || weather.isError,
};
}

View file

@ -0,0 +1,19 @@
/* ============================================================
DashboardPage - Header Layout
============================================================ */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: var(--color-text);
}

View file

@ -1,13 +1,19 @@
import { useAuth } from '../auth/AuthContext';
import { WeatherWidget } from '../components/WeatherWidget';
import styles from './DashboardPage.module.css';
export function DashboardPage() {
const { user } = useAuth();
return (
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '1.5rem' }}>
Dashboard
</h1>
{/* Header: Titel links, Wetter rechts */}
<div className={styles.header}>
<h1 className={styles.title}>
Willkommen, {user?.firstName} {user?.lastName}
</h1>
<WeatherWidget city={user?.city} />
</div>
<div style={{
background: 'var(--color-bg-card)',
@ -16,9 +22,6 @@ export function DashboardPage() {
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
}}>
<h2 style={{ fontSize: '1.125rem', marginBottom: '1rem' }}>
Willkommen, {user?.firstName}!
</h2>
<p style={{ color: 'var(--color-text-secondary)' }}>
INSIGHT Platform - Sprint 1 Alpha
</p>