feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen

PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden

PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-06 10:54:26 +02:00
parent 9298216ce0
commit 3b05e949dd
15 changed files with 1400 additions and 251 deletions
+56
View File
@@ -274,6 +274,40 @@ func (s *Store) initSchema(ctx context.Context) error {
ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to TEXT;
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails (thread_id);
`)
if err != nil {
return err
}
// PROJ-13: API keys for external CRM integration
_, err = s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS api_keys (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
active BOOLEAN NOT NULL DEFAULT TRUE,
rate_limit INT NOT NULL DEFAULT 60,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_api_keys_token_hash ON api_keys(token_hash);
`)
if err != nil {
return err
}
// PROJ-42: Gespeicherte Suchanfragen
_, err = s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS saved_searches (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
query_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_saved_searches_user ON saved_searches(user_id, tenant_id);
`)
return err
}
@@ -1295,3 +1329,25 @@ func (s *Store) DBQueryRow(ctx context.Context, sql string, args ...interface{})
type noopRow struct{}
func (n *noopRow) Scan(dest ...interface{}) error { return nil }
// DBExec exposes a single DB exec for use by API handlers (e.g., API key management).
// Returns the number of rows affected. Returns 0 if no DB is configured.
func (s *Store) DBExec(ctx context.Context, sql string, args ...interface{}) (int64, error) {
if s.db == nil {
return 0, nil
}
tag, err := s.db.Exec(ctx, sql, args...)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// DBQuery exposes a multi-row DB query for use by API handlers (e.g., API key listing).
// Returns nil rows if no DB is configured.
func (s *Store) DBQuery(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
if s.db == nil {
return nil, fmt.Errorf("storage: no database configured")
}
return s.db.Query(ctx, sql, args...)
}