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:
Tomas DvorakandDevin committed 2026-09-24 19:27:06 +02:00
1 parent 45a1621909
commit f747b33b46
24 files changed
+669 -16

No files matched your search

+2
View File
@@ -1722,6 +1722,8 @@ export default function App() {
<Show when={activeView() === "members"}>
<MembersPage
projectID={activeProject()?.id}
demoMode={isDemo}
organizationMembers={organizationMembers()}
organizationInvitations={organizationInvitations()}
projectMembers={projectMembers()}
+61 -5
View File
@@ -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()}