e60d34dc28
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
367 lines
14 KiB
Markdown
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
|