feat(PROJ-9): implement labels backend - DB schema, labelstore, API handlers, Xapian integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
// Package labelstore manages labels, email-label assignments, and auto-label
|
||||
// rules in PostgreSQL. Part of PROJ-9: Ordner- & Label-Verwaltung.
|
||||
package labelstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Label represents a user-defined or global (admin) label.
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
OwnerID *int64 `json:"owner_id,omitempty"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
IsGlobal bool `json:"is_global"`
|
||||
}
|
||||
|
||||
// LabelRule describes an automatic label assignment rule (admin-only).
|
||||
type LabelRule struct {
|
||||
ID int64 `json:"id"`
|
||||
ConditionField string `json:"condition_field"`
|
||||
ConditionValue string `json:"condition_value"`
|
||||
LabelID int64 `json:"label_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// Store is a PostgreSQL-backed label store.
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#6366f1',
|
||||
owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(name, owner_id, tenant_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_labels (
|
||||
email_id VARCHAR(64) NOT NULL,
|
||||
label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
assigned_by VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
PRIMARY KEY (email_id, label_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS label_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
condition_field VARCHAR(30) NOT NULL,
|
||||
condition_value VARCHAR(255) NOT NULL,
|
||||
label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_labels_label_id ON email_labels(label_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_labels_email_id ON email_labels(email_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_label_rules_tenant_id ON label_rules(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_tenant_id ON labels(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_owner_id ON labels(owner_id);
|
||||
`
|
||||
|
||||
// New connects to PostgreSQL and initialises the labels schema.
|
||||
func New(dsn string) (*Store, error) {
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: connect: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: pool}
|
||||
if err := s.initSchema(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("labelstore: init schema: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) initSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, schemaSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the underlying connection pool.
|
||||
func (s *Store) Close() error {
|
||||
s.db.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Label CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
// GetLabelsForUser returns the user's own labels plus global labels (owner_id IS NULL)
|
||||
// for the given tenant.
|
||||
func (s *Store) GetLabelsForUser(ctx context.Context, userID int64, tenantID int64) ([]Label, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, name, color, owner_id, tenant_id, created_at
|
||||
FROM labels
|
||||
WHERE tenant_id = $1 AND (owner_id = $2 OR owner_id IS NULL)
|
||||
ORDER BY name
|
||||
`, tenantID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: get labels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var labels []Label
|
||||
for rows.Next() {
|
||||
var l Label
|
||||
if err := rows.Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("labelstore: scan label: %w", err)
|
||||
}
|
||||
l.IsGlobal = l.OwnerID == nil
|
||||
labels = append(labels, l)
|
||||
}
|
||||
return labels, rows.Err()
|
||||
}
|
||||
|
||||
// CreateLabel creates a user-owned label.
|
||||
func (s *Store) CreateLabel(ctx context.Context, name, color string, ownerID *int64, tenantID int64) (Label, error) {
|
||||
var l Label
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO labels (name, color, owner_id, tenant_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, color, owner_id, tenant_id, created_at
|
||||
`, name, color, ownerID, tenantID).Scan(&l.ID, &l.Name, &l.Color, &l.OwnerID, &l.TenantID, &l.CreatedAt)
|
||||
if err != nil {
|
||||
return Label{}, fmt.Errorf("labelstore: create label: %w", err)
|
||||
}
|
||||
l.IsGlobal = l.OwnerID == nil
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// UpdateLabel updates name and color of a label owned by the given user.
|
||||
func (s *Store) UpdateLabel(ctx context.Context, labelID int64, name, color string, userID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE labels SET name = $1, color = $2
|
||||
WHERE id = $3 AND owner_id = $4
|
||||
`, name, color, labelID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("labelstore: update label: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("labelstore: label not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLabel deletes a label owned by the given user.
|
||||
func (s *Store) DeleteLabel(ctx context.Context, labelID int64, userID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM labels WHERE id = $1 AND owner_id = $2
|
||||
`, labelID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("labelstore: delete label: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("labelstore: label not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Email-Label Assignment ────────────────────────────────────────────────
|
||||
|
||||
// AssignLabel assigns a label to an email. Duplicate assignments are silently ignored.
|
||||
func (s *Store) AssignLabel(ctx context.Context, emailID string, labelID int64, assignedBy string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO email_labels (email_id, label_id, assigned_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email_id, label_id) DO NOTHING
|
||||
`, emailID, labelID, assignedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("labelstore: assign label: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLabel removes a label assignment from an email.
|
||||
func (s *Store) RemoveLabel(ctx context.Context, emailID string, labelID int64) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
DELETE FROM email_labels WHERE email_id = $1 AND label_id = $2
|
||||
`, emailID, labelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("labelstore: remove label: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLabelIDsForEmail returns all label IDs assigned to a given email.
|
||||
func (s *Store) GetLabelIDsForEmail(ctx context.Context, emailID string) ([]int64, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT label_id FROM email_labels WHERE email_id = $1
|
||||
`, emailID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: get label ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("labelstore: scan label id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// GetEmailIDsByLabel returns all email IDs that have a specific label assigned.
|
||||
func (s *Store) GetEmailIDsByLabel(ctx context.Context, labelID int64) ([]string, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT email_id FROM email_labels WHERE label_id = $1
|
||||
`, labelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: get email ids by label: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("labelstore: scan email id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ── Admin Labels (Global) ─────────────────────────────────────────────────
|
||||
|
||||
// CreateAdminLabel creates a global label (owner_id = NULL) for a tenant.
|
||||
func (s *Store) CreateAdminLabel(ctx context.Context, name, color string, tenantID int64) (Label, error) {
|
||||
return s.CreateLabel(ctx, name, color, nil, tenantID)
|
||||
}
|
||||
|
||||
// ── Label Rules ───────────────────────────────────────────────────────────
|
||||
|
||||
// GetLabelRules returns all auto-label rules for a tenant.
|
||||
func (s *Store) GetLabelRules(ctx context.Context, tenantID int64) ([]LabelRule, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, condition_field, condition_value, label_id, tenant_id
|
||||
FROM label_rules
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY id
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: get rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []LabelRule
|
||||
for rows.Next() {
|
||||
var r LabelRule
|
||||
if err := rows.Scan(&r.ID, &r.ConditionField, &r.ConditionValue, &r.LabelID, &r.TenantID); err != nil {
|
||||
return nil, fmt.Errorf("labelstore: scan rule: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
// CreateLabelRule creates an auto-label rule for a tenant.
|
||||
func (s *Store) CreateLabelRule(ctx context.Context, field, value string, labelID int64, tenantID int64) (LabelRule, error) {
|
||||
var r LabelRule
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO label_rules (condition_field, condition_value, label_id, tenant_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, condition_field, condition_value, label_id, tenant_id
|
||||
`, field, value, labelID, tenantID).Scan(&r.ID, &r.ConditionField, &r.ConditionValue, &r.LabelID, &r.TenantID)
|
||||
if err != nil {
|
||||
return LabelRule{}, fmt.Errorf("labelstore: create rule: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// DeleteLabelRule deletes an auto-label rule within a tenant.
|
||||
func (s *Store) DeleteLabelRule(ctx context.Context, ruleID int64, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM label_rules WHERE id = $1 AND tenant_id = $2
|
||||
`, ruleID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("labelstore: delete rule: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("labelstore: rule not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Batch Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// GetLabelsForEmails returns a map of email_id -> []int64 (label IDs) for a
|
||||
// batch of email IDs. Useful for enriching search results.
|
||||
func (s *Store) GetLabelsForEmails(ctx context.Context, emailIDs []string) (map[string][]int64, error) {
|
||||
if len(emailIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT email_id, label_id FROM email_labels WHERE email_id = ANY($1)
|
||||
`, emailIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("labelstore: get labels for emails: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]int64)
|
||||
for rows.Next() {
|
||||
var emailID string
|
||||
var labelID int64
|
||||
if err := rows.Scan(&emailID, &labelID); err != nil {
|
||||
return nil, fmt.Errorf("labelstore: scan: %w", err)
|
||||
}
|
||||
result[emailID] = append(result[emailID], labelID)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user