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:
@@ -0,0 +1,47 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"archivmail/internal/auth"
|
||||
)
|
||||
|
||||
// LookupAPIKey resolves an API key by its SHA-256 token hash.
|
||||
// Returns nil if not found or if the key is inactive.
|
||||
func (s *Store) LookupAPIKey(ctx context.Context, tokenHash string) (*auth.APIKeyRow, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no database configured")
|
||||
}
|
||||
|
||||
row := s.db.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, name, role, active, rate_limit
|
||||
FROM api_keys
|
||||
WHERE token_hash = $1`,
|
||||
tokenHash,
|
||||
)
|
||||
|
||||
var k auth.APIKeyRow
|
||||
err := row.Scan(&k.ID, &k.TenantID, &k.Name, &k.Role, &k.Active, &k.RateLimit)
|
||||
if err != nil {
|
||||
// pgx returns no rows as an error; treat as "not found".
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// TouchAPIKeyLastUsed updates the last_used_at timestamp for the given key ID.
|
||||
func (s *Store) TouchAPIKeyLastUsed(ctx context.Context, keyID int64) error {
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE api_keys SET last_used_at = NOW() WHERE id = $1`,
|
||||
keyID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: touch api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SavedSearch represents a user's saved search query (PROJ-42).
|
||||
type SavedSearch struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
QueryJSON []byte `json:"query"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListSavedSearches returns all saved searches for the given user and tenant.
|
||||
func (s *Store) ListSavedSearches(ctx context.Context, userID, tenantID int64) ([]SavedSearch, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, tenant_id, name, query_json, created_at
|
||||
FROM saved_searches
|
||||
WHERE user_id = $1 AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
`, userID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("saved_searches: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []SavedSearch
|
||||
for rows.Next() {
|
||||
var ss SavedSearch
|
||||
if err := rows.Scan(&ss.ID, &ss.UserID, &ss.TenantID, &ss.Name, &ss.QueryJSON, &ss.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("saved_searches: scan: %w", err)
|
||||
}
|
||||
result = append(result, ss)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CreateSavedSearch inserts a new saved search and returns it.
|
||||
func (s *Store) CreateSavedSearch(ctx context.Context, userID, tenantID int64, name string, queryJSON []byte) (*SavedSearch, error) {
|
||||
ss := &SavedSearch{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
QueryJSON: queryJSON,
|
||||
}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO saved_searches (user_id, tenant_id, name, query_json)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at
|
||||
`, userID, tenantID, name, queryJSON).Scan(&ss.ID, &ss.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("saved_searches: create: %w", err)
|
||||
}
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// DeleteSavedSearch deletes a saved search. Ownership is enforced by requiring
|
||||
// both userID and tenantID to match the row.
|
||||
func (s *Store) DeleteSavedSearch(ctx context.Context, id, userID, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM saved_searches
|
||||
WHERE id = $1 AND user_id = $2 AND tenant_id = $3
|
||||
`, id, userID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("saved_searches: delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("saved_searches: not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user