mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51: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
+669
-16
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.
|
- **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.
|
- **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 …`.
|
- **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
|
## [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"`).
|
the received body as payload (`triggered_by: "hook"`).
|
||||||
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
|
- 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
|
## Future phases — candidates
|
||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
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"`
|
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 {
|
type CoreEvent struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type Querier interface {
|
|||||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||||
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
||||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, 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)
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, 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)
|
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||||
|
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, 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.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||||
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||||
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
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/events", h.listEvents)
|
||||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
api.GET("/projects/:projectID/components", h.listComponents)
|
api.GET("/projects/:projectID/components", h.listComponents)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -80,6 +81,25 @@ func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
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
|
// receiveInboundHook is the public ingest endpoint — the URL token is the
|
||||||
// credential, so no actor is required.
|
// credential, so no actor is required.
|
||||||
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
@@ -239,10 +240,11 @@ type alertEvaluator struct {
|
|||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
started sync.Once
|
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 {
|
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{})}
|
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) {
|
func (e *alertEvaluator) Start(ctx context.Context) {
|
||||||
@@ -255,8 +257,11 @@ func (e *alertEvaluator) Start(ctx context.Context) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
e.scan(ctx)
|
if e.leader.hold(ctx) {
|
||||||
|
e.scan(ctx)
|
||||||
|
}
|
||||||
case <-e.quit:
|
case <-e.quit:
|
||||||
|
e.leader.stop(context.Background())
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
@@ -406,6 +407,7 @@ type JobScheduler struct {
|
|||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
started sync.Once
|
started sync.Once
|
||||||
|
leader *leaderState
|
||||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
// 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)
|
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||||
}
|
}
|
||||||
@@ -415,7 +417,7 @@ type jobExec struct {
|
|||||||
run db.CoreScheduledJobRun
|
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 {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
@@ -428,6 +430,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
|
|||||||
queue: make(chan jobExec, 256),
|
queue: make(chan jobExec, 256),
|
||||||
quit: make(chan struct{}),
|
quit: make(chan struct{}),
|
||||||
onEvent: onEvent,
|
onEvent: onEvent,
|
||||||
|
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,8 +455,13 @@ func (j *JobScheduler) run(ctx context.Context) {
|
|||||||
case exec := <-j.queue:
|
case exec := <-j.queue:
|
||||||
j.execute(exec.job, exec.run)
|
j.execute(exec.job, exec.run)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
j.scan()
|
// 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:
|
case <-j.quit:
|
||||||
|
j.leader.stop(context.Background())
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
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"
|
"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 {
|
type Mailer struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
resend *resend.Client
|
resend *resend.Client
|
||||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
|||||||
return &Mailer{cfg: cfg, resend: resendClient}
|
return &Mailer{cfg: cfg, resend: resendClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
// mailTemplate renders a subject + plaintext body from template data.
|
||||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
|
||||||
|
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 {
|
if m.resend != nil {
|
||||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||||
From: m.cfg.MailFrom,
|
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 {
|
if enc != nil {
|
||||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||||
s.dispatcher.Start(context.Background())
|
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.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())
|
s.alerts.Start(context.Background())
|
||||||
}
|
}
|
||||||
return s
|
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.
|
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
||||||
func (s *PlatformService) Close() {
|
func (s *PlatformService) Close() {
|
||||||
if s.dispatcher != nil {
|
if s.dispatcher != nil {
|
||||||
@@ -913,7 +977,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
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 nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
|||||||
@@ -2142,6 +2142,31 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -4159,3 +4184,32 @@ components:
|
|||||||
run_id:
|
run_id:
|
||||||
type: string
|
type: string
|
||||||
required: [received]
|
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"}>
|
<Show when={activeView() === "members"}>
|
||||||
<MembersPage
|
<MembersPage
|
||||||
|
projectID={activeProject()?.id}
|
||||||
|
demoMode={isDemo}
|
||||||
organizationMembers={organizationMembers()}
|
organizationMembers={organizationMembers()}
|
||||||
organizationInvitations={organizationInvitations()}
|
organizationInvitations={organizationInvitations()}
|
||||||
projectMembers={projectMembers()}
|
projectMembers={projectMembers()}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import type {
|
import {
|
||||||
OrganizationInvitation,
|
EmailService,
|
||||||
OrganizationMember,
|
type EmailLogEntry,
|
||||||
ProjectMember,
|
type OrganizationInvitation,
|
||||||
|
type OrganizationMember,
|
||||||
|
type ProjectMember,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
import { Modal, ModalFooter } from "../components/Modal";
|
import { Modal, ModalFooter } from "../components/Modal";
|
||||||
@@ -22,6 +24,8 @@ interface InvitationInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MembersPageProps {
|
interface MembersPageProps {
|
||||||
|
projectID?: string;
|
||||||
|
demoMode?: boolean;
|
||||||
organizationMembers?: OrganizationMember[];
|
organizationMembers?: OrganizationMember[];
|
||||||
organizationInvitations?: OrganizationInvitation[];
|
organizationInvitations?: OrganizationInvitation[];
|
||||||
projectMembers?: ProjectMember[];
|
projectMembers?: ProjectMember[];
|
||||||
@@ -80,6 +84,15 @@ export function MembersPage(props: MembersPageProps) {
|
|||||||
const [tab, setTab] = createSignal<"org" | "project">("org");
|
const [tab, setTab] = createSignal<"org" | "project">("org");
|
||||||
const [inviteOpen, setInviteOpen] = createSignal(false);
|
const [inviteOpen, setInviteOpen] = createSignal(false);
|
||||||
const [query, setQuery] = createSignal("");
|
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 filteredOrgMembers = createMemo(() => {
|
||||||
const q = query().toLowerCase();
|
const q = query().toLowerCase();
|
||||||
@@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 */}
|
{/* Invite modal */}
|
||||||
<Modal
|
<Modal
|
||||||
open={inviteOpen()}
|
open={inviteOpen()}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export type { DeployMarker } from './models/DeployMarker';
|
|||||||
export type { Document } from './models/Document';
|
export type { Document } from './models/Document';
|
||||||
export type { DocumentID } from './models/DocumentID';
|
export type { DocumentID } from './models/DocumentID';
|
||||||
export type { DocumentListResponse } from './models/DocumentListResponse';
|
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 { ErrorEnvelope } from './models/ErrorEnvelope';
|
||||||
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
||||||
export type { HealthStatus } from './models/HealthStatus';
|
export type { HealthStatus } from './models/HealthStatus';
|
||||||
@@ -207,6 +209,8 @@ export { $DeployMarker } from './schemas/$DeployMarker';
|
|||||||
export { $Document } from './schemas/$Document';
|
export { $Document } from './schemas/$Document';
|
||||||
export { $DocumentID } from './schemas/$DocumentID';
|
export { $DocumentID } from './schemas/$DocumentID';
|
||||||
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
||||||
|
export { $EmailLogEntry } from './schemas/$EmailLogEntry';
|
||||||
|
export { $EmailLogListResponse } from './schemas/$EmailLogListResponse';
|
||||||
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
||||||
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
||||||
export { $HealthStatus } from './schemas/$HealthStatus';
|
export { $HealthStatus } from './schemas/$HealthStatus';
|
||||||
@@ -291,6 +295,7 @@ export { AlertsService } from './services/AlertsService';
|
|||||||
export { AutomationService } from './services/AutomationService';
|
export { AutomationService } from './services/AutomationService';
|
||||||
export { CollectionsService } from './services/CollectionsService';
|
export { CollectionsService } from './services/CollectionsService';
|
||||||
export { DatabasesService } from './services/DatabasesService';
|
export { DatabasesService } from './services/DatabasesService';
|
||||||
|
export { EmailService } from './services/EmailService';
|
||||||
export { HealthService } from './services/HealthService';
|
export { HealthService } from './services/HealthService';
|
||||||
export { InboundService } from './services/InboundService';
|
export { InboundService } from './services/InboundService';
|
||||||
export { InstanceService } from './services/InstanceService';
|
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