Compare commits

...

15 Commits

Author SHA1 Message Date
m
260f65ea02 feat: auto-calculate deadlines on proceeding type selection (no click needed) 2026-03-30 19:41:02 +02:00
m
501b573967 fix: use typed category field instead of Record cast 2026-03-30 19:37:52 +02:00
m
23b8ef4bba chore: gitignore server binary and local state files 2026-03-30 19:34:34 +02:00
m
54c6eb8dae feat: 15 UPC proceeding types in 3 groups + category field
Added 10 new UPC types: DNI, EPO, AMD, CCI, EVP, DAM, COS, REH, DEF, RST.
Grouped as: Hauptverfahren / Verfahren im Verfahren / Rechtsbehelfe.
Frontend dropdown shows sub-groups within jurisdiction. German names throughout.
2026-03-30 19:34:07 +02:00
m
967f2f6d09 feat: direct SMTP email sending via Hostinger (replaces m CLI) 2026-03-30 17:28:40 +02:00
m
e5387734aa fix: use mgmt@msbls.de as default MAIL_FROM (alias now exists) 2026-03-30 17:28:11 +02:00
m
6cb87c6868 feat: replace m CLI email with direct SMTP over TLS
The m CLI isn't available in Docker containers. Replace exec.Command("m", "mail", "send")
with direct SMTP using crypto/tls + net/smtp (implicit TLS on port 465).

Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM
Gracefully skips sending if SMTP is not configured.

Note: mgmt@msbls.de rejected by Hostinger as not owned by mail@msbls.de.
Default from address set to mail@msbls.de until alias is created.
2026-03-30 17:23:54 +02:00
m
d38719db2f fix: add email field to UserTenant TypeScript type 2026-03-30 17:19:15 +02:00
m
b21efccfb5 fix: add MAIL_FROM env (default mgmt@msbls.de) + graceful fallback when m CLI unavailable 2026-03-30 17:10:25 +02:00
m
f51d189a3b fix: show member email instead of UUID in team management 2026-03-30 17:09:14 +02:00
m
481b299e03 test: comprehensive integration tests for all API endpoints 2026-03-30 14:43:32 +02:00
m
68d48100b9 test: comprehensive integration tests for all API endpoints
Replace the existing integration test suite with a complete test covering
every registered API route. Tests use httptest with the real router and
a real DB connection (youpc.org mgmt schema).

Endpoint groups tested:
- Health, Auth (JWT validation, expired/invalid/wrong-secret)
- Current user (GET /api/me)
- Tenants (CRUD, auto-assign)
- Cases (CRUD with search/status filters)
- Parties (CRUD)
- Deadlines (CRUD, complete, batch create)
- Appointments (CRUD)
- Notes (CRUD)
- Dashboard
- Proceeding types & deadline rules
- Deadline calculator & determination (timeline, determine)
- Reports (cases, deadlines, workload, billing)
- Templates (CRUD, render)
- Time entries (CRUD, summary)
- Invoices (CRUD, status update)
- Billing rates (list, upsert)
- Notifications (list, unread count, mark read, preferences)
- Audit log (list, filtered)
- Case assignments (assign, unassign)
- Documents (list, meta)
- AI endpoints (availability check)
- Critical path E2E (case -> deadline -> appointment -> note -> time entry -> dashboard -> complete)
2026-03-30 14:41:59 +02:00
m
40a11a4c49 feat: group proceeding types by jurisdiction (UPC/DE) + add German patent proceedings 2026-03-30 14:33:28 +02:00
m
eca0cde5e7 fix: timeline 404 + calculate endpoint fixes 2026-03-30 14:32:51 +02:00
m
dea49f6f8e feat: group proceeding types by jurisdiction in UI dropdowns
- DeadlineCalculator: use optgroup to group by UPC/DE
- DeadlineWizard: add section headers for each jurisdiction
- CaseForm: replace hardcoded TYPE_OPTIONS with API-fetched
  proceeding types grouped by jurisdiction
- Added 3 new DE proceeding types to DB: DE_PATENT,
  DE_NULLITY, DE_OPPOSITION
2026-03-30 14:29:42 +02:00
12 changed files with 1320 additions and 288 deletions

6
.gitignore vendored
View File

@@ -46,3 +46,9 @@ tmp/
# TypeScript
*.tsbuildinfo
.worktrees/
backend/server
backend/.m/
.m/inbox_lastread
backend/server
backend/.m/
.m/inbox_lastread

View File

@@ -14,6 +14,13 @@ type Config struct {
SupabaseJWTSecret string
AnthropicAPIKey string
FrontendOrigin string
// SMTP settings (optional — email sending disabled if SMTPHost is empty)
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
MailFrom string
}
func Load() (*Config, error) {
@@ -26,6 +33,12 @@ func Load() (*Config, error) {
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getEnv("SMTP_PORT", "465"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPass: os.Getenv("SMTP_PASS"),
MailFrom: getEnv("MAIL_FROM", "mgmt@msbls.de"),
}
if cfg.DatabaseURL == "" {

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ type ProceedingType struct {
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
Category *string `db:"category" json:"category,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`

View File

@@ -20,6 +20,7 @@ type UserTenant struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
Role string `db:"role" json:"role"`
Email string `db:"email" json:"email"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -2,9 +2,12 @@ package services
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"os/exec"
"net"
"net/smtp"
"os"
"strings"
"sync"
"time"
@@ -457,18 +460,85 @@ type UpdatePreferencesInput struct {
DailyDigest bool `json:"daily_digest"`
}
// SendEmail sends an email using the `m mail send` CLI command.
// SendEmail sends an email via direct SMTP over TLS.
// Requires SMTP_HOST, SMTP_USER, SMTP_PASS env vars. Falls back to no-op if unconfigured.
func SendEmail(to, subject, body string) error {
cmd := exec.Command("m", "mail", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output))
host := os.Getenv("SMTP_HOST")
port := os.Getenv("SMTP_PORT")
user := os.Getenv("SMTP_USER")
pass := os.Getenv("SMTP_PASS")
from := os.Getenv("MAIL_FROM")
if port == "" {
port = "465"
}
slog.Info("email sent", "to", to, "subject", subject)
if from == "" {
from = "mgmt@msbls.de"
}
if host == "" || user == "" || pass == "" {
slog.Warn("SMTP not configured, skipping email", "to", to, "subject", subject)
return nil
}
// Build RFC 2822 message
msg := fmt.Sprintf("From: \"KanzlAI-mGMT\" <%s>\r\n"+
"To: %s\r\n"+
"Subject: [KanzlAI] %s\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: text/plain; charset=utf-8\r\n"+
"Date: %s\r\n"+
"\r\n%s",
from, to, subject,
time.Now().Format(time.RFC1123Z),
body)
addr := net.JoinHostPort(host, port)
// Connect with implicit TLS (port 465)
tlsConfig := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("smtp tls dial: %w", err)
}
client, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp new client: %w", err)
}
defer client.Close()
// Authenticate
auth := smtp.PlainAuth("", user, pass, host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
// Send
if err := client.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
if err := client.Quit(); err != nil {
slog.Warn("smtp quit error (non-fatal)", "error", err)
}
slog.Info("email sent via SMTP", "from", from, "to", to, "subject", subject)
return nil
}

View File

@@ -139,7 +139,11 @@ func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
var members []models.UserTenant
err := s.db.SelectContext(ctx, &members,
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`,
`SELECT ut.user_id, ut.tenant_id, ut.role, ut.created_at, COALESCE(au.email, '') as email
FROM user_tenants ut
LEFT JOIN auth.users au ON au.id = ut.user_id
WHERE ut.tenant_id = $1
ORDER BY ut.created_at`,
tenantID,
)
if err != nil {

View File

@@ -1,16 +1,14 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ProceedingType } from "@/lib/types";
const TYPE_OPTIONS = [
{ value: "", label: "-- Typ wählen --" },
{ value: "INF", label: "Verletzungsklage (INF)" },
{ value: "REV", label: "Widerruf (REV)" },
{ value: "CCR", label: "Einstweilige Verfügung (CCR)" },
{ value: "APP", label: "Berufung (APP)" },
{ value: "PI", label: "Vorläufiger Rechtsschutz (PI)" },
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
];
const JURISDICTION_LABELS: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
export interface CaseFormData {
case_number: string;
@@ -34,6 +32,10 @@ export function CaseForm({
isSubmitting,
submitLabel = "Akte anlegen",
}: CaseFormProps) {
const { data: proceedingTypes } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
});
const [form, setForm] = useState<CaseFormData>({
case_number: initialData?.case_number ?? "",
title: initialData?.title ?? "",
@@ -139,11 +141,24 @@ export function CaseForm({
onChange={(e) => update("case_type", e.target.value)}
className={inputClass}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
<option value="">-- Typ wählen --</option>
{(() => {
const grouped = new Map<string, ProceedingType[]>();
for (const pt of proceedingTypes ?? []) {
const key = pt.jurisdiction ?? "Sonstige";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(pt);
}
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
<optgroup key={jurisdiction} label={JURISDICTION_LABELS[jurisdiction] ?? jurisdiction}>
{types.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
</option>
))}
</optgroup>
));
})()}
</select>
</div>
<div>

View File

@@ -51,13 +51,28 @@ export function DeadlineCalculator() {
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
});
// Auto-calculate when proceeding type changes (using current trigger date)
function doCalculate(type: string, date: string) {
if (!type || !date) return;
calculateMutation.mutate({
proceeding_type: type,
trigger_event_date: date,
});
}
function handleProceedingChange(newType: string) {
setProceedingType(newType);
doCalculate(newType, triggerDate);
}
function handleDateChange(newDate: string) {
setTriggerDate(newDate);
if (proceedingType) doCalculate(proceedingType, newDate);
}
function handleCalculate(e: React.FormEvent) {
e.preventDefault();
if (!proceedingType || !triggerDate) return;
calculateMutation.mutate({
proceeding_type: proceedingType,
trigger_event_date: triggerDate,
});
doCalculate(proceedingType, triggerDate);
}
const results = calculateMutation.data;
@@ -80,16 +95,48 @@ export function DeadlineCalculator() {
</label>
<select
value={proceedingType}
onChange={(e) => setProceedingType(e.target.value)}
onChange={(e) => handleProceedingChange(e.target.value)}
disabled={typesLoading}
className={inputClass}
>
<option value="">Bitte wählen...</option>
{proceedingTypes?.map((pt) => (
{(() => {
const types = proceedingTypes ?? [];
const categoryLabels: Record<string, string> = {
hauptverfahren: "Hauptverfahren",
im_verfahren: "Verfahren im Verfahren",
rechtsbehelf: "Rechtsbehelfe",
};
const jurisdictionLabels: Record<string, string> = {
UPC: "UPC",
DE: "Deutsche Patentverfahren",
};
// Group by jurisdiction + category
const groups: { key: string; label: string; items: typeof types }[] = [];
const seen = new Set<string>();
for (const pt of types) {
const j = pt.jurisdiction ?? "Sonstige";
const c = pt.category ?? "hauptverfahren";
const key = `${j}::${c}`;
if (!seen.has(key)) {
seen.add(key);
const jLabel = jurisdictionLabels[j] ?? j;
const cLabel = categoryLabels[c] ?? c;
const label = j === "DE" ? jLabel : `${jLabel}${cLabel}`;
groups.push({ key, label, items: [] });
}
groups.find((g) => g.key === key)!.items.push(pt);
}
return groups.map((g) => (
<optgroup key={g.key} label={g.label}>
{g.items.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
{pt.name}
</option>
))}
</optgroup>
));
})()}
</select>
</div>
<div>
@@ -99,7 +146,7 @@ export function DeadlineCalculator() {
<input
type="date"
value={triggerDate}
onChange={(e) => setTriggerDate(e.target.value)}
onChange={(e) => handleDateChange(e.target.value)}
className={inputClass}
/>
</div>

View File

@@ -276,13 +276,30 @@ export function DeadlineWizard() {
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="mt-4 space-y-4">
{typesLoading ? (
<div className="col-span-full flex justify-center py-4">
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : (
proceedingTypes?.map((pt) => (
(() => {
const grouped = new Map<string, ProceedingType[]>();
for (const pt of proceedingTypes ?? []) {
const key = pt.jurisdiction ?? "Sonstige";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(pt);
}
const labels: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
<div key={jurisdiction}>
<div className="mb-2 text-xs font-medium text-neutral-500">
{labels[jurisdiction] ?? jurisdiction}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{types.map((pt) => (
<button
key={pt.id}
onClick={() => handleTypeSelect(pt.code)}
@@ -301,7 +318,11 @@ export function DeadlineWizard() {
</div>
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
</button>
))
))}
</div>
</div>
));
})()
)}
</div>
</div>
@@ -444,7 +465,7 @@ export function DeadlineWizard() {
<Scale className="h-6 w-6 text-neutral-400" />
</div>
<p className="mt-3 text-sm font-medium text-neutral-700">
UPC-Fristenbestimmung
Fristenbestimmung
</p>
<p className="mt-1 max-w-sm text-xs text-neutral-500">
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.

View File

@@ -164,7 +164,7 @@ export function TeamSettings() {
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{member.user_id.slice(0, 8)}...
{member.email || member.user_id.slice(0, 8) + "..."}
</p>
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
</div>

View File

@@ -15,6 +15,7 @@ export interface UserTenant {
user_id: string;
tenant_id: string;
role: string;
email: string;
created_at: string;
}
@@ -196,6 +197,7 @@ export interface ProceedingType {
name: string;
description?: string;
jurisdiction?: string;
category?: string;
default_color: string;
sort_order: number;
is_active: boolean;