Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ Two orthogonal concepts: **`type`** = how it renders, **`category`** = where it
|---|---|---|
| `error` | `errors` | → Create Issue / + Auto-Fix buttons (when the project has a repo) |
| `seo_report` | `seo` | search/metrics digest |
| `signup` | `users` | |
| `subscription` | `revenue` | `kind`: new / upgrade / downgrade / cancel / expired / payment_failed / refund / addon; `endsAt`, `source`, `variant`, `subscriptionId` |
| `feedback` | `feedback` | bug/question/feature/general; breadcrumb, steps, browser/viewport meta |
| `signup` | `users` | `kind`: signup / waitlist (waitlist renders with a 📝 + "Waitlist signup" title) |
| `subscription` | `revenue` | `kind`: new / upgrade / downgrade / cancel / expired / payment_failed / refund / addon / addon_cancel; `endsAt`, `source`, `variant`, `subscriptionId` |
| `feedback` | `feedback` | bug/question/feature/general; breadcrumb, steps, browser/viewport meta, `consoleErrors[]` (rendered as a Console errors sub-section + included in filed issues) |
| `cron` | `ops` | run summaries; supports a monospace `table` |
| `infra` | `ops` | homelab/infra alerts |
| `booking` | `bookings` | action card with Email / Add-to-Calendar / Reschedule URL buttons |
Expand Down
38 changes: 17 additions & 21 deletions app/(console)/previews/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { asc, eq } from 'drizzle-orm';

import { db } from '@/lib/db';
import { projects } from '@/lib/db/schema';
import { parseEvent, type EventType } from '@/lib/events/schemas';
import { parseEvent } from '@/lib/events/schemas';
import { ingestEvent } from '@/lib/ingest';
import type { AuthedProject } from '@/lib/auth';
import { SAMPLE_TYPES, SAMPLES } from '@/lib/render/samples';
import { SAMPLE_ENTRIES, sampleById, type SampleId } from '@/lib/render/samples';

export type SendPreviewResult = { ok: true; status: string } | { error: string };

Expand Down Expand Up @@ -35,12 +35,12 @@ async function resolveProject(slug?: string): Promise<AuthedProject | { error: s
return row as AuthedProject;
}

/** Post a single sample event through the real ingest pipeline. */
export async function sendPreview(type: EventType, projectSlug?: string): Promise<SendPreviewResult> {
const sample = SAMPLES[type];
if (sample === undefined) return { error: `No sample for type "${type}".` };
/** Post a single sample event (addressed by its stable id) through the real ingest pipeline. */
export async function sendPreview(sampleId: SampleId, projectSlug?: string): Promise<SendPreviewResult> {
const sample = sampleById(sampleId);
if (!sample) return { error: `No sample with id "${sampleId}".` };

const parsed = parseEvent(sample);
const parsed = parseEvent(sample.envelope);
if (!parsed.success) return { error: `Invalid sample: ${parsed.error}` };

const project = await resolveProject(projectSlug);
Expand All @@ -56,35 +56,31 @@ export async function sendPreview(type: EventType, projectSlug?: string): Promis

export type FireAllResult = {
project: string;
results: { type: EventType; status: string; error?: string }[];
results: { id: SampleId; label: string; status: string; error?: string }[];
};

/**
* Fire one of every event type sequentially. Sequential (not parallel) so the
* order in Slack is deterministic and we stay well under any rate limits.
* Returns a per-type result list for the UI to display.
* Fire every sample (one per type, plus variant kinds) sequentially through the
* real ingest pipeline. Sequential (not parallel) so the order in Slack is
* deterministic and we stay well under any rate limit. Returns a per-sample
* result list for the UI to display.
*/
export async function fireAllPreviews(projectSlug?: string): Promise<FireAllResult | { error: string }> {
const project = await resolveProject(projectSlug);
if ('error' in project) return project;

const results: FireAllResult['results'] = [];
for (const type of SAMPLE_TYPES) {
const sample = SAMPLES[type];
if (sample === undefined) {
results.push({ type, status: 'skipped', error: 'no sample defined' });
continue;
}
const parsed = parseEvent(sample);
for (const sample of SAMPLE_ENTRIES) {
const parsed = parseEvent(sample.envelope);
if (!parsed.success) {
results.push({ type, status: 'failed', error: parsed.error });
results.push({ id: sample.id, label: sample.label, status: 'failed', error: parsed.error });
continue;
}
try {
const r = await ingestEvent(project, parsed.data);
results.push({ type, status: r.status });
results.push({ id: sample.id, label: sample.label, status: r.status });
} catch (err) {
results.push({ type, status: 'failed', error: err instanceof Error ? err.message : String(err) });
results.push({ id: sample.id, label: sample.label, status: 'failed', error: err instanceof Error ? err.message : String(err) });
}
}
return { project: project.slug, results };
Expand Down
7 changes: 4 additions & 3 deletions app/(console)/previews/fire-all-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';
import { Flame } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { SAMPLE_ENTRIES } from '@/lib/render/samples';
import { fireAllPreviews, type FireAllResult } from './actions';

/**
Expand All @@ -28,7 +29,7 @@ export function FireAllButton({ projectSlug }: { projectSlug: string }) {
<div className="space-y-2">
<Button onClick={onClick} disabled={pending} variant="default" size="sm">
<Flame className="size-3.5" />
{pending ? `Firing all 11 types…` : `Fire all 11 types at ${projectSlug}`}
{pending ? `Firing all ${SAMPLE_ENTRIES.length} samples…` : `Fire all ${SAMPLE_ENTRIES.length} samples at ${projectSlug}`}
</Button>

{result && 'error' in result ? (
Expand All @@ -46,11 +47,11 @@ export function FireAllButton({ projectSlug }: { projectSlug: string }) {
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400';
return (
<span
key={r.type}
key={r.id}
title={r.error ?? r.status}
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono ${cls}`}
>
<span className="font-medium">{r.type}</span>
<span className="font-medium">{r.label}</span>
<span className="opacity-70">{failed ? 'fail' : r.status}</span>
</span>
);
Expand Down
29 changes: 14 additions & 15 deletions app/(console)/previews/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { db } from '@/lib/db';
import { projects } from '@/lib/db/schema';
import { parseEvent, type EventType } from '@/lib/events/schemas';
import { parseEvent } from '@/lib/events/schemas';
import { renderEvent } from '@/lib/render';
import type { SlackBlock } from '@/lib/render/blocks';
import { mrkdwnToHtml } from '@/lib/render/mrkdwn-to-html';
import { SAMPLES, SAMPLE_TYPES } from '@/lib/render/samples';
import { SAMPLE_ENTRIES, type Sample } from '@/lib/render/samples';
import { FireAllButton } from './fire-all-button';
import { ProjectPicker } from './project-picker';
import { SendLiveButton } from './send-button';
Expand Down Expand Up @@ -80,15 +80,14 @@ function ActionsBlock({ block }: { block: SlackBlock }) {
);
}

function PreviewCard({ type, previewSlug }: { type: EventType; previewSlug: string }) {
const sample = SAMPLES[type];
const parsed = parseEvent(sample);
function PreviewCard({ entry, previewSlug }: { entry: Sample; previewSlug: string }) {
const parsed = parseEvent(entry.envelope);

if (!parsed.success) {
return (
<Card>
<CardHeader className="flex-row items-center justify-between gap-3">
<code className="text-sm font-medium">{type}</code>
<code className="text-sm font-medium">{entry.label}</code>
<Badge variant="destructive">parse error</Badge>
</CardHeader>
<CardContent>
Expand All @@ -103,7 +102,7 @@ function PreviewCard({ type, previewSlug }: { type: EventType; previewSlug: stri
githubRepo: 'mattdecrevel/example',
autofixEnabled: true,
});
const isRaw = type === 'raw';
const isRaw = entry.type === 'raw';

// Split body section(s) from the trailing context footer (house-style only).
const footer = !isRaw && blocks.at(-1)?.type === 'context' ? blockText(blocks.at(-1)!) : null;
Expand All @@ -113,10 +112,10 @@ function PreviewCard({ type, previewSlug }: { type: EventType; previewSlug: stri
<Card>
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
<div className="flex items-center gap-2">
<code className="text-sm font-medium">{type}</code>
<code className="text-sm font-medium">{entry.label}</code>
{isRaw ? <Badge variant="secondary">exempt</Badge> : null}
</div>
<SendLiveButton type={type} projectSlug={previewSlug} />
<SendLiveButton sampleId={entry.id} projectSlug={previewSlug} />
</CardHeader>

<CardContent className="flex flex-col gap-4">
Expand Down Expand Up @@ -197,10 +196,10 @@ export default async function PreviewsPage({ searchParams }: PageProps) {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Previews</h1>
<p className="text-sm text-muted-foreground">
Every message type rendered in the &ldquo;Rich hybrid&rdquo; house style. &ldquo;Send live&rdquo; posts the real
sample through the selected project&apos;s pipeline; &ldquo;Fire all 11 types&rdquo; sends one of each in
order so you can verify routing + rendering end-to-end. Dashed pills are server-handled
(action_id) buttons.
Every message type — plus key variant kinds (waitlist signup, add-on cancel) — rendered in
the &ldquo;Rich hybrid&rdquo; house style. &ldquo;Send live&rdquo; posts the real sample through the
selected project&apos;s pipeline; &ldquo;Fire all&rdquo; sends one of each in order so you can verify
routing + rendering end-to-end. Dashed pills are server-handled (action_id) buttons.
</p>
</div>

Expand All @@ -221,8 +220,8 @@ export default async function PreviewsPage({ searchParams }: PageProps) {
</div>

<div className="grid gap-5 lg:grid-cols-2">
{SAMPLE_TYPES.map((type) => (
<PreviewCard key={type} type={type} previewSlug={selectedSlug} />
{SAMPLE_ENTRIES.map((entry) => (
<PreviewCard key={entry.id} entry={entry} previewSlug={selectedSlug} />
))}
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions app/(console)/previews/send-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import * as React from 'react';
import { Send } from 'lucide-react';

import { Button } from '@/components/ui/button';
import type { EventType } from '@/lib/events/schemas';
import type { SampleId } from '@/lib/render/samples';
import { sendPreview } from './actions';

export function SendLiveButton({ type, projectSlug }: { type: EventType; projectSlug: string }) {
export function SendLiveButton({ sampleId, projectSlug }: { sampleId: SampleId; projectSlug: string }) {
const [pending, setPending] = React.useState(false);
const [status, setStatus] = React.useState<{ kind: 'ok' | 'error'; text: string } | null>(null);

async function onClick() {
setPending(true);
setStatus(null);
const result = await sendPreview(type, projectSlug);
const result = await sendPreview(sampleId, projectSlug);
setPending(false);
if ('error' in result) {
setStatus({ kind: 'error', text: result.error });
Expand Down
5 changes: 3 additions & 2 deletions lib/events/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const linkSchema = z.object({ label: z.string(), url: z.string() });
const payloads = {
error: z.object({ message: z.string(), route: z.string().optional(), stack: z.string().optional(), source: z.string().optional() }),
seo_report: z.object({ siteLabel: z.string(), clicks: z.number(), impressions: z.number(), topQueries: z.array(z.string()).optional(), subSections: z.array(subSectionSchema).optional() }),
signup: z.object({ email: z.string(), name: z.string().optional() }),
subscription: z.object({ email: z.string(), kind: z.enum(['new', 'upgrade', 'downgrade', 'cancel', 'expired', 'payment_failed', 'refund', 'addon']), plan: z.string().optional(), amount: z.number().optional(), interval: z.string().optional(), endsAt: z.string().optional(), source: z.string().optional(), variant: z.string().optional(), subscriptionId: z.string().optional() }),
signup: z.object({ email: z.string(), name: z.string().optional(), kind: z.enum(['signup', 'waitlist']).optional() }),
subscription: z.object({ email: z.string(), kind: z.enum(['new', 'upgrade', 'downgrade', 'cancel', 'expired', 'payment_failed', 'refund', 'addon', 'addon_cancel']), plan: z.string().optional(), amount: z.number().optional(), interval: z.string().optional(), endsAt: z.string().optional(), source: z.string().optional(), variant: z.string().optional(), subscriptionId: z.string().optional() }),
feedback: z.object({
category: z.enum(['bug', 'question', 'feature', 'general']),
message: z.string(),
Expand All @@ -41,6 +41,7 @@ const payloads = {
locale: z.string().optional(),
timezone: z.string().optional(),
pageUrl: z.string().optional(),
consoleErrors: z.array(z.string()).optional(),
}),
cron: z.object({ name: z.string(), ok: z.boolean(), summary: z.string().optional(), table: tableSchema.optional(), bullets: z.array(z.string()).optional() }),
infra: z.object({ host: z.string(), message: z.string(), metric: z.string().optional() }),
Expand Down
5 changes: 5 additions & 0 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export function buildIssue(type: string, payload: Record<string, unknown>): Issu
lines.push('', '**Steps:**');
(p.steps as unknown[]).forEach((s, i) => lines.push(`${i + 1}. ${String(s)}`));
}
if (Array.isArray(p.consoleErrors) && p.consoleErrors.length) {
lines.push('', '**Console errors:**', '```');
(p.consoleErrors as unknown[]).slice(-10).forEach((e) => lines.push(String(e)));
lines.push('```');
}
const meta: string[] = [];
if (p.userEmail) meta.push(`From: ${String(p.userEmail)}`);
const page = p.page ?? p.pageUrl;
Expand Down
18 changes: 13 additions & 5 deletions lib/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ function pickEmoji(ev: ParsedEvent): string {
switch (ev.type) {
case 'error': return '🚨';
case 'feedback': return ({ bug: '🐛', question: '❓', feature: '💡', general: '💬' } as Record<string, string>)[p.category] ?? '💬';
case 'subscription': return ({ new: '🎉', upgrade: '⬆️', downgrade: '⬇️', cancel: '👋', expired: '⌛', payment_failed: '⚠️', refund: '💸', addon: '➕' } as Record<string, string>)[p.kind] ?? '💳';
case 'signup': return '👤';
case 'subscription': return ({ new: '🎉', upgrade: '⬆️', downgrade: '⬇️', cancel: '👋', expired: '⌛', payment_failed: '⚠️', refund: '💸', addon: '➕', addon_cancel: '➖' } as Record<string, string>)[p.kind] ?? '💳';
case 'signup': return p.kind === 'waitlist' ? '📝' : '👤';
case 'cron': return p.ok ? '✅' : '⚠️';
case 'infra': return ev.severity === 'error' ? '🔴' : '📡';
case 'booking': return '📅';
Expand Down Expand Up @@ -108,22 +108,30 @@ export function renderEvent(ev: ParsedEvent, ctx?: RenderContext): Rendered {
...linkActions,
];
const hasActions = feedbackIssueActions.length || feedbackUrlActions.length;
// Captured console errors render as a dedicated sub-section (newest last,
// capped) so the report carries the failing client context with it.
const consoleErrs = Array.isArray(p.consoleErrors) ? p.consoleErrors.filter(Boolean) : [];
const feedbackSubSections = consoleErrs.length
? [{ header: 'Console errors', lines: consoleErrs.slice(-10).map((e: string) => `\`${String(e)}\``) }]
: undefined;
return {
text: `Feedback — ${p.category}`,
blocks: richMessage({
...base, emoji, title, subject, breadcrumb: p.breadcrumb,
body: p.message, steps: p.steps, meta,
body: p.message, steps: p.steps, subSections: feedbackSubSections, meta,
...(hasActions ? { interactiveActions: feedbackIssueActions, actions: feedbackUrlActions, divider: true } : {}),
}),
};
}

case 'signup': {
const identity = p.name ? `${p.name} (${p.email})` : p.email;
const isWaitlist = p.kind === 'waitlist';
const title = isWaitlist ? 'Waitlist signup' : 'New signup';
return {
text: `New signup — ${identity}`,
text: `${isWaitlist ? 'New waitlist signup' : 'New signup'} — ${identity}`,
blocks: richMessage({
...base, emoji, title: 'New signup', subject: identity,
...base, emoji, title, subject: identity,
}),
};
}
Expand Down
Loading
Loading