Skip to content

Commit eb79043

Browse files
add: codra update emails support
1 parent c218a9e commit eb79043

9 files changed

Lines changed: 313 additions & 26 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useEffect, useState, type FormEvent } from 'react';
2+
import { toast } from 'sonner';
3+
import { Check, Mail, RefreshCw } from 'lucide-react';
4+
import { api } from '@client/lib/api';
5+
import { Button } from '@client/components/ui/button';
6+
import { Input } from '@client/components/ui/input';
7+
import type { UpdatesEmailResponse } from '@shared/api';
8+
9+
export function UpdatesEmailPrompt() {
10+
const [status, setStatus] = useState<UpdatesEmailResponse | null>(null);
11+
const [email, setEmail] = useState('');
12+
const [submitting, setSubmitting] = useState(false);
13+
14+
useEffect(() => {
15+
let cancelled = false;
16+
api.getUpdatesEmailStatus()
17+
.then((response) => {
18+
if (!cancelled) setStatus(response);
19+
})
20+
.catch(() => {
21+
if (!cancelled) setStatus(null);
22+
});
23+
24+
return () => {
25+
cancelled = true;
26+
};
27+
}, []);
28+
29+
if (status?.status !== 'pending') return null;
30+
31+
const subscribe = async (event: FormEvent<HTMLFormElement>) => {
32+
event.preventDefault();
33+
setSubmitting(true);
34+
35+
try {
36+
const response = await api.subscribeUpdates(email);
37+
setStatus(response);
38+
toast.success('Updates email saved', {
39+
description: 'You will only get important Codra release and security notes.',
40+
});
41+
} catch (error) {
42+
toast.error('Could not save updates email', {
43+
description: error instanceof Error ? error.message : 'Please try again.',
44+
});
45+
} finally {
46+
setSubmitting(false);
47+
}
48+
};
49+
50+
return (
51+
<section className="surface overflow-hidden">
52+
<div className="flex flex-col gap-4 px-4 py-4 sm:px-5 lg:flex-row lg:items-center lg:justify-between">
53+
<div className="flex min-w-0 items-start gap-3">
54+
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
55+
<Mail size={16} strokeWidth={2.1} />
56+
</span>
57+
<div className="min-w-0">
58+
<h2 className="text-sm font-semibold text-foreground">Get important Codra updates</h2>
59+
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
60+
Add an email for release notes, security fixes, and upgrade heads-up. You can opt out from any update email later. No spam.
61+
</p>
62+
</div>
63+
</div>
64+
65+
<form onSubmit={subscribe} className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center lg:w-[31rem]">
66+
<Input
67+
type="email"
68+
required
69+
value={email}
70+
onChange={(event) => setEmail(event.target.value)}
71+
placeholder="you@example.com"
72+
className="h-9 min-w-0 flex-1"
73+
aria-label="Email for Codra release updates"
74+
/>
75+
<div className="flex shrink-0 gap-2">
76+
<Button type="submit" size="sm" disabled={submitting} className="flex-1 gap-2 sm:flex-none">
77+
{submitting ? <RefreshCw size={13} className="animate-spin" /> : <Check size={13} />}
78+
Save email
79+
</Button>
80+
</div>
81+
</form>
82+
</div>
83+
</section>
84+
);
85+
}

src/client/components/features/job-detail/job-header.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Link } from 'react-router-dom';
22
import { ChevronRight, ExternalLink, RotateCcw, Terminal } from 'lucide-react';
33
import { Button } from '@client/components/ui/button';
4+
import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt';
45
import type { JobDetail } from '@shared/schema';
56

67
interface JobHeaderProps {
@@ -11,7 +12,8 @@ interface JobHeaderProps {
1112

1213
export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) {
1314
return (
14-
<header className="flex flex-col sm:flex-row items-start justify-between gap-4">
15+
<>
16+
<header className="flex flex-col sm:flex-row items-start justify-between gap-4">
1517
<div className="min-w-0 w-full">
1618
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
1719
<Link to="/jobs" className="hover:text-foreground transition-colors">Jobs</Link>
@@ -58,6 +60,8 @@ export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) {
5860
{isRetrying ? 'Starting…' : job.status === 'failed' ? 'Retry job' : 'Re-run job'}
5961
</Button>
6062
</div>
61-
</header>
63+
</header>
64+
<UpdatesEmailPrompt />
65+
</>
6266
);
6367
}
Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import { cn } from '@client/lib/utils';
3+
import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt';
34

45
interface PageHeaderProps extends React.HTMLAttributes<HTMLElement> {
56
category: string;
@@ -17,31 +18,34 @@ export function PageHeader({
1718
...props
1819
}: PageHeaderProps) {
1920
return (
20-
<header
21-
className={cn('flex flex-col sm:flex-row sm:items-end justify-between gap-4 sm:gap-0', className)}
22-
{...props}
23-
>
24-
<div>
25-
<p className="text-xs font-semibold uppercase tracking-widest text-primary/70 mb-1">
26-
{category}
27-
</p>
28-
<h1
29-
className="text-xl md:text-2xl font-bold text-foreground"
30-
style={{ letterSpacing: '-0.025em' }}
31-
>
32-
{title}
33-
</h1>
34-
{description && (
35-
<div className="mt-1 text-sm text-muted-foreground">
36-
{description}
21+
<>
22+
<header
23+
className={cn('flex flex-col sm:flex-row sm:items-end justify-between gap-4 sm:gap-0', className)}
24+
{...props}
25+
>
26+
<div>
27+
<p className="text-xs font-semibold uppercase tracking-widest text-primary/70 mb-1">
28+
{category}
29+
</p>
30+
<h1
31+
className="text-xl md:text-2xl font-bold text-foreground"
32+
style={{ letterSpacing: '-0.025em' }}
33+
>
34+
{title}
35+
</h1>
36+
{description && (
37+
<div className="mt-1 text-sm text-muted-foreground">
38+
{description}
39+
</div>
40+
)}
41+
</div>
42+
{actions && (
43+
<div className="flex flex-wrap items-center gap-2 sm:gap-3 w-full sm:w-auto">
44+
{actions}
3745
</div>
3846
)}
39-
</div>
40-
{actions && (
41-
<div className="flex flex-wrap items-center gap-2 sm:gap-3 w-full sm:w-auto">
42-
{actions}
43-
</div>
44-
)}
45-
</header>
47+
</header>
48+
<UpdatesEmailPrompt />
49+
</>
4650
);
4751
}

src/client/lib/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
RetryJobResponse,
1010
StatsResponse,
1111
SyncReposResponse,
12+
UpdatesEmailResponse,
1213
} from '@shared/api';
1314

1415
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -59,6 +60,15 @@ export const api = {
5960
method: 'POST',
6061
});
6162
},
63+
getUpdatesEmailStatus() {
64+
return request<UpdatesEmailResponse>('/api/auth/updates-email');
65+
},
66+
subscribeUpdates(email: string) {
67+
return request<UpdatesEmailResponse>('/api/auth/updates-email', {
68+
method: 'POST',
69+
body: JSON.stringify({ email }),
70+
});
71+
},
6272
getJobs(params: Record<string, any> = {}) {
6373
const searchParams = new URLSearchParams();
6474
for (const [key, value] of Object.entries(params)) {

src/server/core/updates-email.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { AppBindings } from '@server/env';
2+
3+
const EMAILS_API_URL = 'https://codra.run/api/emails';
4+
5+
type UpdatesEmailRecord = {
6+
status: 'subscribed';
7+
email: string;
8+
updatedAt: string;
9+
};
10+
11+
function updatesEmailKey(githubUserId: number) {
12+
return `updates-email:${githubUserId}`;
13+
}
14+
15+
export async function getUpdatesEmailPreference(
16+
env: Pick<AppBindings, 'APP_KV'>,
17+
githubUserId: number,
18+
) {
19+
return await env.APP_KV.get(updatesEmailKey(githubUserId), 'json') as UpdatesEmailRecord | null;
20+
}
21+
22+
export async function hasUpdatesEmailPreference(
23+
env: Pick<AppBindings, 'APP_KV'>,
24+
githubUserId: number,
25+
) {
26+
return Boolean(await getUpdatesEmailPreference(env, githubUserId));
27+
}
28+
29+
export async function syncUpdatesEmail(
30+
env: Pick<AppBindings, 'APP_KV'>,
31+
githubUserId: number,
32+
email: string | null | undefined,
33+
) {
34+
if (!email) return false;
35+
36+
if (await hasUpdatesEmailPreference(env, githubUserId)) return false;
37+
38+
const response = await fetch(EMAILS_API_URL, {
39+
method: 'POST',
40+
headers: { 'content-type': 'application/json' },
41+
body: JSON.stringify({ email }),
42+
});
43+
44+
if (!response.ok) return false;
45+
46+
const record: UpdatesEmailRecord = {
47+
status: 'subscribed',
48+
email,
49+
updatedAt: new Date().toISOString(),
50+
};
51+
await env.APP_KV.put(updatesEmailKey(githubUserId), JSON.stringify(record));
52+
53+
return true;
54+
}

src/server/routes/api/auth.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { Hono } from 'hono';
2+
import { z } from 'zod';
3+
import { jsonError } from '@server/core/http';
4+
import { getUpdatesEmailPreference, syncUpdatesEmail } from '@server/core/updates-email';
25
import type { AppEnv } from '@server/env';
36

7+
const emailSchema = z.object({
8+
email: z.string().trim().email().max(254),
9+
}).strict();
10+
411
export function createAuthApiRouter() {
512
const app = new Hono<AppEnv>();
613

@@ -13,5 +20,54 @@ export function createAuthApiRouter() {
1320
return c.json({ user: sessionUser });
1421
});
1522

23+
app.get('/updates-email', async (c) => {
24+
const sessionUser = c.get('sessionUser');
25+
if (!sessionUser) {
26+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
27+
}
28+
29+
const preference = await getUpdatesEmailPreference(c.env, sessionUser.githubUserId);
30+
return c.json({
31+
status: preference?.status ?? 'pending',
32+
email: preference?.email ?? null,
33+
updatedAt: preference?.updatedAt ?? null,
34+
});
35+
});
36+
37+
app.post('/updates-email', async (c) => {
38+
const sessionUser = c.get('sessionUser');
39+
if (!sessionUser) {
40+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
41+
}
42+
43+
const body = await c.req.json().catch(() => null);
44+
const parsed = emailSchema.safeParse(body);
45+
if (!parsed.success) {
46+
return jsonError('Enter a valid email address.', 400);
47+
}
48+
49+
const existingPreference = await getUpdatesEmailPreference(c.env, sessionUser.githubUserId);
50+
if (existingPreference) {
51+
return c.json({
52+
status: existingPreference.status,
53+
email: existingPreference.email,
54+
updatedAt: existingPreference.updatedAt,
55+
});
56+
}
57+
58+
const synced = await syncUpdatesEmail(c.env, sessionUser.githubUserId, parsed.data.email);
59+
if (!synced) {
60+
return jsonError('Could not save updates email right now.', 502);
61+
}
62+
63+
const preference = await getUpdatesEmailPreference(c.env, sessionUser.githubUserId);
64+
65+
return c.json({
66+
status: preference?.status ?? 'pending',
67+
email: preference?.email ?? null,
68+
updatedAt: preference?.updatedAt ?? null,
69+
});
70+
});
71+
1672
return app;
1773
}

src/shared/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ export type AuthSessionResponse = {
2222
user: AuthSessionUser;
2323
};
2424

25+
export type UpdatesEmailStatus = 'pending' | 'subscribed';
26+
27+
export type UpdatesEmailResponse = {
28+
status: UpdatesEmailStatus;
29+
email: string | null;
30+
updatedAt: string | null;
31+
};
32+
2533
export type JobDetailResponse = {
2634
job: JobDetail;
2735
};

0 commit comments

Comments
 (0)