Files

367 lines
14 KiB
Markdown

# Phase 5: Dashboard & Calendar - Pattern Map
**Mapped:** 2026-06-23
**Files analyzed:** 30
**Analogs found:** 24 / 30
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/prisma/schema.prisma` (modify) | model | CRUD | selbst | exact |
| `apps/api/src/dashboard/dashboard.module.ts` | config | request-response | `apps/api/src/module-registry/module-registry.module.ts` | exact |
| `apps/api/src/dashboard/dashboard.controller.ts` | controller | CRUD | `apps/api/src/module-registry/module-registry.controller.ts` | exact |
| `apps/api/src/dashboard/dashboard.service.ts` | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` | exact |
| `apps/api/src/dashboard/dto/save-layout.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact |
| `apps/api/src/dashboard/dto/update-widget-config.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact |
| `apps/api/src/dashboard/dto/create-widget.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact |
| `apps/api/src/calendar/calendar.module.ts` | config | request-response | `apps/api/src/module-registry/module-registry.module.ts` | exact |
| `apps/api/src/calendar/calendar.controller.ts` | controller | CRUD | `apps/api/src/module-registry/module-registry.controller.ts` | exact |
| `apps/api/src/calendar/calendar.service.ts` | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` | role-match |
| `apps/api/src/calendar/providers/caldav.provider.ts` | service | request-response | -- | no-analog |
| `apps/api/src/calendar/providers/ics.provider.ts` | service | request-response | -- | no-analog |
| `apps/api/src/calendar/providers/exchange.provider.ts` | service | request-response | -- | no-analog |
| `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact |
| `apps/api/src/calendar/dto/calendar-events-query.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact |
| `apps/web/src/app/(portal)/page.tsx` (modify) | component | request-response | selbst | exact |
| `apps/web/src/app/(portal)/settings/layout.tsx` | component | request-response | `apps/web/src/app/(portal)/layout.tsx` | role-match |
| `apps/web/src/app/(portal)/settings/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match |
| `apps/web/src/app/(portal)/settings/dashboard/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match |
| `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match |
| `apps/web/src/app/(portal)/settings/dashboard/search/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match |
| `apps/web/src/components/dashboard/dashboard-grid.tsx` | component | event-driven | -- | no-analog |
| `apps/web/src/components/dashboard/edit-mode-toggle.tsx` | component | event-driven | `apps/web/src/components/theme-toggle.tsx` | role-match |
| `apps/web/src/components/dashboard/widget-catalog-modal.tsx` | component | event-driven | -- | no-analog |
| `apps/web/src/components/dashboard/widgets/clock-widget.tsx` | component | event-driven | -- | no-analog |
| `apps/web/src/components/dashboard/widgets/search-widget.tsx` | component | request-response | -- | no-analog |
| `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` | component | request-response | -- | no-analog |
| `apps/web/src/components/dashboard/widgets/note-widget.tsx` | component | event-driven | -- | no-analog |
| `apps/web/src/components/dashboard/widgets/widget-wrapper.tsx` | component | request-response | -- | no-analog |
| `apps/web/src/components/settings/settings-sidebar.tsx` | component | request-response | `apps/web/src/components/layout/sidebar.tsx` | role-match |
| `apps/web/src/lib/stores/dashboard-store.ts` | store | CRUD | `apps/web/src/lib/stores/sidebar-store.ts` | role-match |
| `apps/web/src/components/layout/header.tsx` (modify) | component | request-response | selbst | exact |
## Pattern Assignments
### NestJS Module Pattern (dashboard.module.ts, calendar.module.ts)
**Analog:** `apps/api/src/module-registry/module-registry.module.ts`
**Vollstaendiges Pattern** (Zeilen 1-22):
```typescript
import { Module } from '@nestjs/common';
import { ModuleRegistryController } from './module-registry.controller';
import { ModuleRegistryService } from './module-registry.service';
import { ModuleGuard } from './module.guard';
@Module({
controllers: [ModuleRegistryController],
providers: [ModuleRegistryService, ModuleGuard],
exports: [ModuleRegistryService, ModuleGuard],
})
export class ModuleRegistryModule {}
```
---
### NestJS Controller Pattern (dashboard.controller.ts, calendar.controller.ts)
**Analog:** `apps/api/src/module-registry/module-registry.controller.ts`
**Imports Pattern** (Zeilen 1-14):
```typescript
import {
Controller,
ForbiddenException,
Get,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
```
**Tenant-Context Extraction** (Zeilen 48-53, wiederholt in allen Methoden):
```typescript
@Get('active')
async findActive(@Req() req: Request) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
return this.moduleRegistryService.findActiveForTenant(tenantId);
}
```
**Hinweis fuer Dashboard:** Dashboard-Endpoints brauchen `userId` zusaetzlich zu `tenantId`. Pattern: `const userId = (req as any).user?.id;`
---
### NestJS Service Pattern (dashboard.service.ts, calendar.service.ts)
**Analog:** `apps/api/src/module-registry/module-registry.service.ts`
**Imports + Constructor** (Zeilen 1-12):
```typescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ModuleRegistryService {
constructor(private readonly prisma: PrismaService) {}
```
**CRUD Pattern - findMany** (Zeilen 17-21):
```typescript
async findAll() {
return this.prisma.module.findMany({
orderBy: { name: 'asc' },
});
}
```
**CRUD Pattern - upsert** (Zeilen 53-82):
```typescript
async activateForTenant(tenantId: string, moduleId: string) {
const moduleExists = await this.prisma.module.findUnique({
where: { id: moduleId },
});
if (!moduleExists) {
throw new NotFoundException(`Module with id '${moduleId}' not found`);
}
return this.prisma.tenantModuleActivation.upsert({
where: { tenantId_moduleId: { tenantId, moduleId } },
update: { isActive: true, activatedAt: new Date() },
create: { tenantId, moduleId, isActive: true },
include: { module: true },
});
}
```
---
### DTO Pattern (alle dto/*.dto.ts)
**Analog:** `apps/api/src/domaincheck/dto/check-domain.dto.ts`
**Vollstaendiges Pattern** (Zeilen 1-34):
```typescript
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
Matches,
} from 'class-validator';
export class CheckDomainDto {
@IsString()
@IsNotEmpty()
@Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, {
message: 'Domain must be a valid label...',
})
domain!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(10, { message: 'Maximum 10 TLDs per request' })
tlds?: string[];
}
```
**Wichtig fuer Dashboard DTOs:** `save-layout.dto.ts` braucht `IsObject` oder `IsJSON` fuer das JSONB layouts-Feld. `class-validator` hat `IsObject()` und `IsNotEmptyObject()`.
---
### Prisma Schema Pattern (schema.prisma erweitern)
**Analog:** `apps/api/prisma/schema.prisma` — bestehende Models
**Model mit JSONB + userId + tenantId** (Pattern aus User + LdapConfig):
```prisma
model LdapConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: [tenantId], references: [id])
serverUrl String
// ... fields ...
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
```
**Neue Models folgen diesem Pattern:** `@id @default(uuid())`, `userId String`, `tenantId String`, `createdAt`/`updatedAt`, `@@index` auf userId und tenantId. JSONB-Felder als `Json` Typ.
---
### Zustand Store Pattern (dashboard-store.ts)
**Analog:** `apps/web/src/lib/stores/sidebar-store.ts`
**Store mit Persist** (Zeilen 1-21):
```typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SidebarState {
isCollapsed: boolean;
isMobileOpen: boolean;
toggle: () => void;
setMobileOpen: (open: boolean) => void;
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
isCollapsed: false,
isMobileOpen: false,
toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
setMobileOpen: (open) => set({ isMobileOpen: open }),
}),
{ name: 'tessera-sidebar' }
)
);
```
**Hinweis:** Dashboard-Store soll NICHT persist verwenden (Layout kommt aus DB, nicht LocalStorage). Einfacher Store ohne persist wie `marketplace-store.ts`:
```typescript
import { create } from 'zustand';
export const useMarketplaceStore = create<MarketplaceState>()((set) => ({
// ...
}));
```
---
### Next.js Page Pattern (alle settings pages)
**Analog:** `apps/web/src/app/(portal)/page.tsx`
**Client Component mit next-intl** (Zeilen 1-6):
```typescript
'use client';
import { useTranslations } from 'next-intl';
export default function DashboardPage() {
const t = useTranslations('dashboard');
```
**Tailwind + OKLCH Farbklassen** (Zeilen 9-11):
```typescript
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div className="rounded-lg bg-muted p-4">
<span className="text-muted-foreground">
```
---
### Next.js Layout Pattern (settings/layout.tsx)
**Analog:** `apps/web/src/app/(portal)/layout.tsx`
**Nested Layout** (Zeilen 1-13):
```typescript
import { AppShell } from '@/components/layout/app-shell';
export default function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AppShell>{children}</AppShell>;
}
```
**Settings-Layout wird aehnlich:** Statt `<AppShell>` ein `<div className="flex h-full">` mit `<SettingsSidebar />` und Content-Bereich.
---
### Header Modification (Settings-Link hinzufuegen)
**Analog:** `apps/web/src/components/layout/header.tsx`
**Dropdown-Menu Einfuegepunkt** (Zeilen 130-155): Settings-Link kommt als neues Item zwischen User-Info Block und Logout-Button:
```typescript
{/* Logout button - Zeile 132 */}
<div className="px-2 py-1">
<button
onClick={() => { setDropdownOpen(false); logout(); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
>
```
**Pattern fuer neuen Settings-Link:** Gleiche CSS-Klassen wie Logout-Button, aber mit `<Link href="/settings">` statt `<button onClick={logout}>`. Einfuegen vor dem Logout-`<div>` (vor Zeile 132).
## Shared Patterns
### Tenant-Context Extraction
**Source:** `apps/api/src/module-registry/module-registry.controller.ts` Zeilen 48-53
**Apply to:** Alle Backend-Controller (dashboard.controller.ts, calendar.controller.ts)
```typescript
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
```
### User-Context Extraction (neu fuer Dashboard)
**Source:** Abgeleitet aus Auth-Pattern
**Apply to:** Alle Dashboard/Calendar-Endpoints (user-scoped, nicht tenant-scoped)
```typescript
const userId = (req as any).user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
```
### API Fetch Pattern (Frontend)
**Source:** `apps/web/src/components/layout/header.tsx` Zeile 21 (fetchCurrentUser)
**Apply to:** Dashboard-Store, Widget-Komponenten, Settings-Seiten
```typescript
// Alle API-Calls nutzen credentials: 'include' fuer Cookie-Auth
await fetch('/api/dashboard/layout', {
credentials: 'include',
});
```
### i18n Pattern
**Source:** `apps/web/src/app/(portal)/page.tsx` Zeilen 1-6
**Apply to:** Alle Frontend-Komponenten
```typescript
'use client';
import { useTranslations } from 'next-intl';
const t = useTranslations('dashboard');
// Verwendung: {t('empty')}, {t('addWidget')}
```
### Tailwind UI Pattern
**Source:** `apps/web/src/components/layout/header.tsx`
**Apply to:** Alle neuen Komponenten
- Farben: `bg-muted`, `text-muted-foreground`, `bg-primary`, `text-primary-foreground`, `border-border`, `bg-card`, `text-foreground`
- Hover: `hover:bg-muted`, `hover:opacity-90`, `transition-colors`
- Layout: `flex`, `items-center`, `gap-2`, `rounded-md`, `px-4 py-2`
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/api/src/calendar/providers/caldav.provider.ts` | service | request-response | Kein externer API-Client-Pattern im Projekt. RESEARCH.md Pattern (tsdav) verwenden |
| `apps/api/src/calendar/providers/ics.provider.ts` | service | request-response | Kein HTTP-Fetch + Parse Pattern. RESEARCH.md Pattern (node-ical) verwenden |
| `apps/api/src/calendar/providers/exchange.provider.ts` | service | request-response | Kein EWS/Graph Pattern. RESEARCH.md Pattern verwenden |
| `apps/web/src/components/dashboard/dashboard-grid.tsx` | component | event-driven | react-grid-layout ist neu im Projekt. RESEARCH.md Code-Beispiel verwenden |
| `apps/web/src/components/dashboard/widget-catalog-modal.tsx` | component | event-driven | Kein Modal-Pattern im Projekt. shadcn/ui Dialog verwenden |
| `apps/web/src/components/dashboard/widgets/*.tsx` | component | event-driven | Widget-Komponenten sind neues Pattern. RESEARCH.md WidgetProps Interface verwenden |
## Metadata
**Analog search scope:** `apps/api/src/`, `apps/web/src/`
**Files scanned:** ~60
**Pattern extraction date:** 2026-06-23