package automation import ( "context" "encoding/json" "log/slog" "path" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/webhooks" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) type Dispatcher struct { db *pgxpool.Pool rules *rules.Engine hooks *webhooks.Executor logger *slog.Logger } func NewDispatcher(db *pgxpool.Pool, rulesEngine *rules.Engine, hookExec *webhooks.Executor) *Dispatcher { return &Dispatcher{ db: db, rules: rulesEngine, hooks: hookExec, logger: slog.Default().With("component", "automation-dispatcher"), } } type DrivePayload struct { FilePath string FileName string MimeType string FileSize int64 IsFolder bool } type ContactPayload struct { ID string BookID string Name string Email string Phone string Org string Labels []string } func DrivePayloadFromPath(filePath string, isFolder bool) DrivePayload { normalized := nextcloud.NormalizeClientPath(filePath) name := path.Base(strings.TrimSuffix(normalized, "/")) if name == "" || name == "." { name = normalized } return DrivePayload{ FilePath: normalized, FileName: name, IsFolder: isFolder, } } func (d *Dispatcher) OnMailCreated(ctx context.Context, userID, accountID, messageID string, msg *rules.Message) { if d == nil || userID == "" || msg == nil { return } evt := &rules.EventContext{Type: rules.TriggerMessageReceived} if msg.AccountID == "" { msg.AccountID = accountID } if msg.ID == "" { msg.ID = messageID } d.runRules(ctx, userID, msg, evt) d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "") } func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload DrivePayload) { if d == nil || externalUserID == "" { return } userID, err := d.resolveUserID(ctx, externalUserID) if err != nil { d.logger.Error("resolve user for drive automation", "error", err, "sub", externalUserID) return } evt := driveEventContext(trigger, payload) msg := &rules.Message{} d.runRules(ctx, userID, msg, evt) d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "") } func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload ContactPayload) { if d == nil || externalUserID == "" { return } userID, err := d.resolveUserID(ctx, externalUserID) if err != nil { d.logger.Error("resolve user for contact automation", "error", err, "sub", externalUserID) return } evt := contactEventContext(trigger, payload) msg := &rules.Message{} d.runRules(ctx, userID, msg, evt) d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID) } func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) { if d.rules == nil { return } if err := d.rules.EvaluateMessageEvent(ctx, userID, msg, evt); err != nil { d.logger.Error("rules evaluation", "error", err, "user_id", userID, "trigger", evt.Type) } } type webhookTemplateRow struct { id string eventTypes []byte mailScope []byte driveScope []byte contactsScope []byte } func (d *Dispatcher) dispatchWebhooks( ctx context.Context, userID string, eventType string, evt *rules.EventContext, msg *rules.Message, accountID string, drivePath string, bookID string, ) { if d.hooks == nil || d.db == nil { return } rows, err := d.db.Query(ctx, ` SELECT id, event_types, mail_scope, drive_scope, contacts_scope FROM webhook_templates WHERE user_id = $1 AND is_active = true `, userID) if err != nil { d.logger.Error("list webhook templates", "error", err) return } defer rows.Close() msgCtx := rules.WebhookContextFromEvent(evt, msg) for rows.Next() { var row webhookTemplateRow if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope); err != nil { d.logger.Error("scan webhook template", "error", err) continue } if !webhookMatchesEvent(row, eventType) { continue } if !webhookMatchesScope(row, accountID, drivePath, bookID) { continue } if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil { d.logger.Error("webhook dispatch", "template_id", row.id, "error", err) } } } func webhookMatchesEvent(row webhookTemplateRow, eventType string) bool { var types []string if len(row.eventTypes) > 0 { _ = json.Unmarshal(row.eventTypes, &types) } if len(types) == 0 { return false } for _, t := range types { if t == eventType { return true } } return false } func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID string) bool { var mailScope MailScope var driveScope DriveScope var contactsScope ContactsScope _ = json.Unmarshal(row.mailScope, &mailScope) _ = json.Unmarshal(row.driveScope, &driveScope) _ = json.Unmarshal(row.contactsScope, &contactsScope) if accountID != "" { return AllowsMailScope(mailScope, accountID) } if drivePath != "" { return AllowsDriveScope(driveScope, drivePath) } if bookID != "" { return AllowsContactsScope(contactsScope, bookID) } return true } func (d *Dispatcher) resolveUserID(ctx context.Context, externalID string) (string, error) { var userID string err := d.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID) return userID, err } func driveEventContext(trigger rules.TriggerType, p DrivePayload) *rules.EventContext { return &rules.EventContext{ Type: trigger, FolderPath: path.Dir(p.FilePath), DriveFileName: p.FileName, DriveFilePath: p.FilePath, DriveMimeType: p.MimeType, DriveFileSize: p.FileSize, DriveIsFolder: p.IsFolder, } } func contactEventContext(trigger rules.TriggerType, p ContactPayload) *rules.EventContext { return &rules.EventContext{ Type: trigger, ContactLabel: strings.Join(p.Labels, ", "), ContactID: p.ID, ContactName: p.Name, ContactEmail: p.Email, ContactPhone: p.Phone, ContactOrg: p.Org, ContactBookID: p.BookID, } } func EventDomain(trigger rules.TriggerType) string { switch trigger { case rules.TriggerDriveFileCreated, rules.TriggerDriveFileUpdated, rules.TriggerDriveFileDeleted, rules.TriggerDriveFileMoved, rules.TriggerDriveShareUpdated: return "drive" case rules.TriggerContactCreated, rules.TriggerContactUpdated, rules.TriggerContactDeleted: return "contacts" default: return "mail" } } func NowISO() string { return time.Now().UTC().Format(time.RFC3339) }