mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: HA scheduler leadership + templated email log
HA scheduler: the job scheduler and alert evaluator hold session-scoped PostgreSQL advisory locks on pinned pool connections — replicas scan for due work exactly once, failover verified live with two replicas. Manual and hook-triggered runs stay available on any replica. Email surface: mailer renders registered templates (invitation); every send is logged to core.email_log with status and error text. New GET /projects/:id/emails endpoint; Members page shows an Email log card. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
45a1621909
commit
f747b33b46
24 files changed
+667
-14
No files matched your search
@@ -21,6 +21,8 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
||||
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
|
||||
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
|
||||
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
||||
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
||||
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
||||
|
||||
## [0.7.1] - 2026-09-23
|
||||
|
||||
|
||||
+22
@@ -331,6 +331,28 @@ Project-scoped public ingest for external systems.
|
||||
the received body as payload (`triggered_by: "hook"`).
|
||||
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
|
||||
|
||||
## HA scheduler (unreleased)
|
||||
|
||||
Multi-replica safety for the job scheduler and alert evaluator.
|
||||
|
||||
- PostgreSQL advisory locks (session-scoped, separate keys per component)
|
||||
on a dedicated pinned pool connection; only the leader scans for due work.
|
||||
- Manual runs and hook-triggered runs work on any replica — the lock gates
|
||||
only the scheduled scan, so leadership loss never blocks on-demand work.
|
||||
- Released on shutdown; failover verified with a live two-replica test —
|
||||
the survivor acquired leadership and fired the due job.
|
||||
|
||||
## Email surface (unreleased)
|
||||
|
||||
Reusable templates + an operational send log.
|
||||
|
||||
- `core.email_log` (migration 00014) — every send recorded: template,
|
||||
recipient, subject, `sent`/`failed` status, error text.
|
||||
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
|
||||
templates render subject + plaintext body. First template: `invitation`.
|
||||
- `GET /projects/:id/emails` lists recent sends; Members page shows an
|
||||
"Email log" card under pending invitations.
|
||||
|
||||
## Future phases — candidates
|
||||
|
||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.email_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
template TEXT NOT NULL,
|
||||
to_email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('sent','failed')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.email_log;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- name: InsertEmailLog :one
|
||||
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListEmailLog :many
|
||||
SELECT * FROM core.email_log
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2;
|
||||
@@ -0,0 +1,91 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: email_log.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const insertEmailLog = `-- name: InsertEmailLog :one
|
||||
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, project_id, template, to_email, subject, status, error, created_at
|
||||
`
|
||||
|
||||
type InsertEmailLogParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
|
||||
row := q.db.QueryRow(ctx, insertEmailLog,
|
||||
arg.ProjectID,
|
||||
arg.Template,
|
||||
arg.ToEmail,
|
||||
arg.Subject,
|
||||
arg.Status,
|
||||
arg.Error,
|
||||
)
|
||||
var i CoreEmailLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Template,
|
||||
&i.ToEmail,
|
||||
&i.Subject,
|
||||
&i.Status,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEmailLog = `-- name: ListEmailLog :many
|
||||
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListEmailLogParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
|
||||
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreEmailLog
|
||||
for rows.Next() {
|
||||
var i CoreEmailLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Template,
|
||||
&i.ToEmail,
|
||||
&i.Subject,
|
||||
&i.Status,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -243,6 +243,17 @@ type CoreDocument struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreEmailLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
|
||||
@@ -84,6 +84,7 @@ type Querier interface {
|
||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
||||
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||
@@ -100,6 +101,7 @@ type Querier interface {
|
||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||
|
||||
@@ -83,6 +83,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||
api.GET("/projects/:projectID/events", h.listEvents)
|
||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||
api.GET("/projects/:projectID/components", h.listComponents)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -80,6 +81,25 @@ func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listEmailLog(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var limit int64
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
// receiveInboundHook is the public ingest endpoint — the URL token is the
|
||||
// credential, so no actor is required.
|
||||
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
@@ -239,10 +240,11 @@ type alertEvaluator struct {
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started sync.Once
|
||||
leader *leaderState
|
||||
}
|
||||
|
||||
func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger) *alertEvaluator {
|
||||
return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{})}
|
||||
func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator {
|
||||
return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)}
|
||||
}
|
||||
|
||||
func (e *alertEvaluator) Start(ctx context.Context) {
|
||||
@@ -255,8 +257,11 @@ func (e *alertEvaluator) Start(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
if e.leader.hold(ctx) {
|
||||
e.scan(ctx)
|
||||
}
|
||||
case <-e.quit:
|
||||
e.leader.stop(context.Background())
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
@@ -406,6 +407,7 @@ type JobScheduler struct {
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started sync.Once
|
||||
leader *leaderState
|
||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
||||
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||
}
|
||||
@@ -415,7 +417,7 @@ type jobExec struct {
|
||||
run db.CoreScheduledJobRun
|
||||
}
|
||||
|
||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
|
||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
@@ -428,6 +430,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
|
||||
queue: make(chan jobExec, 256),
|
||||
quit: make(chan struct{}),
|
||||
onEvent: onEvent,
|
||||
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,8 +455,13 @@ func (j *JobScheduler) run(ctx context.Context) {
|
||||
case exec := <-j.queue:
|
||||
j.execute(exec.job, exec.run)
|
||||
case <-ticker.C:
|
||||
// Only the leader fires scheduled runs — manual/hook enqueues are
|
||||
// already scoped to whichever replica received the request.
|
||||
if j.leader.hold(ctx) {
|
||||
j.scan()
|
||||
}
|
||||
case <-j.quit:
|
||||
j.leader.stop(context.Background())
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Leader election via Postgres advisory locks. The lock is session-scoped, so
|
||||
// the lease pins its pool connection for its entire lifetime — a handoff back
|
||||
// to the pool would silently drop it. A single-replica deployment simply
|
||||
// always holds the lock; there is no feature flag.
|
||||
//
|
||||
// Distinct keys per component so the job scheduler and the alert evaluator
|
||||
// elect independently.
|
||||
const (
|
||||
leaderKeyScheduler int64 = 728201
|
||||
leaderKeyAlerts int64 = 728202
|
||||
)
|
||||
|
||||
type leaderLease struct {
|
||||
conn *pgxpool.Conn
|
||||
key int64
|
||||
}
|
||||
|
||||
// acquireLeadership returns a lease when this process wins the lock, nil when
|
||||
// another replica holds it, or an error when the check itself failed.
|
||||
func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
|
||||
conn.Release()
|
||||
return nil, err
|
||||
}
|
||||
if !got {
|
||||
conn.Release()
|
||||
return nil, nil
|
||||
}
|
||||
return &leaderLease{conn: conn, key: key}, nil
|
||||
}
|
||||
|
||||
// alive pings the pinned connection — a dead conn means Postgres dropped the
|
||||
// session and released the lock, so leadership is lost even though the lease
|
||||
// object still exists.
|
||||
func (l *leaderLease) alive(ctx context.Context) bool {
|
||||
var one int
|
||||
return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil
|
||||
}
|
||||
|
||||
func (l *leaderLease) release(ctx context.Context) {
|
||||
_, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key)
|
||||
l.conn.Release()
|
||||
}
|
||||
|
||||
// leaderState tracks a lease inside a ticker loop — acquire when absent,
|
||||
// verify when held, drop and re-elect when the connection dies.
|
||||
type leaderState struct {
|
||||
pool *pgxpool.Pool
|
||||
key int64
|
||||
lease *leaderLease
|
||||
}
|
||||
|
||||
func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState {
|
||||
return &leaderState{pool: pool, key: key}
|
||||
}
|
||||
|
||||
// hold reports whether this process currently leads. A nil pool means no
|
||||
// election configured (tests, embedded use) — always lead.
|
||||
func (l *leaderState) hold(ctx context.Context) bool {
|
||||
if l == nil || l.pool == nil {
|
||||
return true
|
||||
}
|
||||
if l.lease != nil {
|
||||
if l.lease.alive(ctx) {
|
||||
return true
|
||||
}
|
||||
l.lease.release(ctx)
|
||||
l.lease = nil
|
||||
}
|
||||
lease, err := acquireLeadership(ctx, l.pool, l.key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
l.lease = lease
|
||||
return lease != nil
|
||||
}
|
||||
|
||||
func (l *leaderState) stop(ctx context.Context) {
|
||||
if l != nil && l.lease != nil {
|
||||
l.lease.release(ctx)
|
||||
l.lease = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"github.com/tdvorak/primora/apps/backend/internal/config"
|
||||
)
|
||||
|
||||
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
||||
// Templates render (subject, body) from data; sends are logged to
|
||||
// core.email_log by the service layer so operators can see what went out.
|
||||
type Mailer struct {
|
||||
cfg config.Config
|
||||
resend *resend.Client
|
||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
||||
return &Mailer{cfg: cfg, resend: resendClient}
|
||||
}
|
||||
|
||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
||||
// mailTemplate renders a subject + plaintext body from template data.
|
||||
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||
|
||||
var mailTemplates = map[string]mailTemplate{
|
||||
"invitation": func(d map[string]string) (string, string) {
|
||||
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
||||
body := fmt.Sprintf(
|
||||
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
||||
d["invite_url"],
|
||||
)
|
||||
return subject, body
|
||||
},
|
||||
}
|
||||
|
||||
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
||||
tpl, ok := mailTemplates[template]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown email template %q", template)
|
||||
}
|
||||
subject, body := tpl(data)
|
||||
return subject, body, nil
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
||||
if m.resend != nil {
|
||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||
From: m.cfg.MailFrom,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMailTemplateInvitation(t *testing.T) {
|
||||
subject, body, err := renderMailTemplate("invitation", map[string]string{
|
||||
"organization": "Acme Corp",
|
||||
"invite_url": "https://app.primora.dev/accept?token=abc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(subject, "Acme Corp") {
|
||||
t.Fatalf("subject missing organization: %q", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
|
||||
t.Fatalf("body missing invite url: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMailTemplateUnknown(t *testing.T) {
|
||||
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
|
||||
t.Fatal("expected error for unknown template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmail(t *testing.T) {
|
||||
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
|
||||
t.Fatalf("extractEmail = %q", got)
|
||||
}
|
||||
if got := extractEmail("[email protected]"); got != "[email protected]" {
|
||||
t.Fatalf("extractEmail bare = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -229,14 +229,78 @@ func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalS
|
||||
if enc != nil {
|
||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||
s.dispatcher.Start(context.Background())
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
|
||||
s.scheduler.Start(context.Background())
|
||||
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger)
|
||||
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||
s.alerts.Start(context.Background())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sendTemplatedEmail renders a registered template, sends it, and writes an
|
||||
// email_log row with the outcome — failed sends are still logged so the
|
||||
// dashboard shows them.
|
||||
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
|
||||
subject, body, err := renderMailTemplate(template, data)
|
||||
status, errText := "sent", ""
|
||||
if err == nil {
|
||||
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
|
||||
status, errText, err = "failed", sendErr.Error(), sendErr
|
||||
}
|
||||
} else {
|
||||
status, errText = "failed", err.Error()
|
||||
}
|
||||
var pid pgtype.UUID
|
||||
if projectID != nil {
|
||||
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
|
||||
}
|
||||
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
|
||||
ProjectID: pid,
|
||||
Template: template,
|
||||
ToEmail: to,
|
||||
Subject: subject,
|
||||
Status: status,
|
||||
Error: errText,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type EmailLogSummary struct {
|
||||
ID string `json:"id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]EmailLogSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = EmailLogSummary{
|
||||
ID: r.ID.String(),
|
||||
Template: r.Template,
|
||||
ToEmail: r.ToEmail,
|
||||
Subject: r.Subject,
|
||||
Status: r.Status,
|
||||
Error: r.Error,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
||||
func (s *PlatformService) Close() {
|
||||
if s.dispatcher != nil {
|
||||
@@ -913,7 +977,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
||||
if err != nil {
|
||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
||||
}
|
||||
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
||||
var inviteProject *uuid.UUID
|
||||
if projectUUID.Valid {
|
||||
id := uuid.UUID(projectUUID.Bytes)
|
||||
inviteProject = &id
|
||||
}
|
||||
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
|
||||
"organization": "Primora",
|
||||
"invite_url": inviteURL,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
|
||||
@@ -2142,6 +2142,31 @@ paths:
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/emails:
|
||||
get:
|
||||
tags: [Email]
|
||||
operationId: listEmailLog
|
||||
summary: Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
maximum: 200
|
||||
responses:
|
||||
"200":
|
||||
description: Email log entries
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EmailLogListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -4159,3 +4184,32 @@ components:
|
||||
run_id:
|
||||
type: string
|
||||
required: [received]
|
||||
EmailLogEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
template:
|
||||
type: string
|
||||
to_email:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [sent, failed]
|
||||
error:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, template, to_email, subject, status, created_at]
|
||||
EmailLogListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EmailLogEntry"
|
||||
required: [items]
|
||||
@@ -1722,6 +1722,8 @@ export default function App() {
|
||||
|
||||
<Show when={activeView() === "members"}>
|
||||
<MembersPage
|
||||
projectID={activeProject()?.id}
|
||||
demoMode={isDemo}
|
||||
organizationMembers={organizationMembers()}
|
||||
organizationInvitations={organizationInvitations()}
|
||||
projectMembers={projectMembers()}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import type {
|
||||
OrganizationInvitation,
|
||||
OrganizationMember,
|
||||
ProjectMember,
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import {
|
||||
EmailService,
|
||||
type EmailLogEntry,
|
||||
type OrganizationInvitation,
|
||||
type OrganizationMember,
|
||||
type ProjectMember,
|
||||
} from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
@@ -22,6 +24,8 @@ interface InvitationInput {
|
||||
}
|
||||
|
||||
interface MembersPageProps {
|
||||
projectID?: string;
|
||||
demoMode?: boolean;
|
||||
organizationMembers?: OrganizationMember[];
|
||||
organizationInvitations?: OrganizationInvitation[];
|
||||
projectMembers?: ProjectMember[];
|
||||
@@ -80,6 +84,15 @@ export function MembersPage(props: MembersPageProps) {
|
||||
const [tab, setTab] = createSignal<"org" | "project">("org");
|
||||
const [inviteOpen, setInviteOpen] = createSignal(false);
|
||||
const [query, setQuery] = createSignal("");
|
||||
const [emails, setEmails] = createSignal<EmailLogEntry[]>([]);
|
||||
|
||||
createEffect(() => {
|
||||
const pid = props.projectID;
|
||||
if (!pid || props.demoMode) return;
|
||||
void EmailService.listEmailLog({ projectId: pid, limit: 20 })
|
||||
.then((res) => setEmails(res.items ?? []))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const filteredOrgMembers = createMemo(() => {
|
||||
const q = query().toLowerCase();
|
||||
@@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={emails().length > 0}>
|
||||
<div class="card card-flush">
|
||||
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||
<span class="card-header-title">Email log</span>
|
||||
<span class="card-header-description">
|
||||
Transactional email sent for this project — every send is recorded, including failures.
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>To</th>
|
||||
<th>Subject</th>
|
||||
<th>Template</th>
|
||||
<th>Status</th>
|
||||
<th>Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={emails()}>
|
||||
{(mail) => (
|
||||
<tr>
|
||||
<td class="font-medium text-text-1">{mail.to_email}</td>
|
||||
<td class="text-text-2" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis" title={mail.error || mail.subject}>
|
||||
{mail.subject}
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant="neutral">{mail.template}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={mail.status === "sent" ? "success" : "error"}>{mail.status}</Badge>
|
||||
</td>
|
||||
<td class="text-text-3">{formatDate(mail.created_at)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Invite modal */}
|
||||
<Modal
|
||||
open={inviteOpen()}
|
||||
|
||||
@@ -67,6 +67,8 @@ export type { DeployMarker } from './models/DeployMarker';
|
||||
export type { Document } from './models/Document';
|
||||
export type { DocumentID } from './models/DocumentID';
|
||||
export type { DocumentListResponse } from './models/DocumentListResponse';
|
||||
export { EmailLogEntry } from './models/EmailLogEntry';
|
||||
export type { EmailLogListResponse } from './models/EmailLogListResponse';
|
||||
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
||||
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
||||
export type { HealthStatus } from './models/HealthStatus';
|
||||
@@ -207,6 +209,8 @@ export { $DeployMarker } from './schemas/$DeployMarker';
|
||||
export { $Document } from './schemas/$Document';
|
||||
export { $DocumentID } from './schemas/$DocumentID';
|
||||
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
||||
export { $EmailLogEntry } from './schemas/$EmailLogEntry';
|
||||
export { $EmailLogListResponse } from './schemas/$EmailLogListResponse';
|
||||
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
||||
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
||||
export { $HealthStatus } from './schemas/$HealthStatus';
|
||||
@@ -291,6 +295,7 @@ export { AlertsService } from './services/AlertsService';
|
||||
export { AutomationService } from './services/AutomationService';
|
||||
export { CollectionsService } from './services/CollectionsService';
|
||||
export { DatabasesService } from './services/DatabasesService';
|
||||
export { EmailService } from './services/EmailService';
|
||||
export { HealthService } from './services/HealthService';
|
||||
export { InboundService } from './services/InboundService';
|
||||
export { InstanceService } from './services/InstanceService';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type EmailLogEntry = {
|
||||
id: string;
|
||||
template: string;
|
||||
to_email: string;
|
||||
subject: string;
|
||||
status: EmailLogEntry.status;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
};
|
||||
export namespace EmailLogEntry {
|
||||
export enum status {
|
||||
SENT = 'sent',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { EmailLogEntry } from './EmailLogEntry';
|
||||
export type EmailLogListResponse = {
|
||||
items: Array<EmailLogEntry>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $EmailLogEntry = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'uuid',
|
||||
},
|
||||
template: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
to_email: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
status: {
|
||||
type: 'Enum',
|
||||
isRequired: true,
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $EmailLogListResponse = {
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'EmailLogEntry',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,36 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { EmailLogListResponse } from '../models/EmailLogListResponse';
|
||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||
import { OpenAPI } from '../core/OpenAPI';
|
||||
import { request as __request } from '../core/request';
|
||||
export class EmailService {
|
||||
/**
|
||||
* Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome.
|
||||
* @returns EmailLogListResponse Email log entries
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listEmailLog({
|
||||
projectId,
|
||||
limit = 50,
|
||||
}: {
|
||||
projectId: string,
|
||||
limit?: number,
|
||||
}): CancelablePromise<EmailLogListResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/emails',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
},
|
||||
query: {
|
||||
'limit': limit,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user