From 4d084eedac6961b20d4de466f1a82907d5f33ce8 Mon Sep 17 00:00:00 2001 From: murugu21 Date: Sun, 14 Jun 2026 13:58:56 +0530 Subject: [PATCH 1/3] fix(web): tick the retry countdown every second RetryCountdown read Date.now() once per render and only re-rendered on the deliveries tab's 5s refetch, so the "in Ns" countdown jumped in ~5s steps. Add a 1s interval that updates a local `now`, so it counts down smoothly and feels real-time, decoupled from the data poll. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/delivery-row.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/delivery-row.tsx b/apps/web/src/components/delivery-row.tsx index 559caff..6054788 100644 --- a/apps/web/src/components/delivery-row.tsx +++ b/apps/web/src/components/delivery-row.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { ChevronDownIcon, ChevronRightIcon, RefreshCwIcon } from "lucide-react"; @@ -54,7 +54,14 @@ export function StatusBadge({ status }: { status: Delivery["status"] }) { // ── Countdown helper ───────────────────────────────────────────────────────── function RetryCountdown({ nextRetryAt }: { nextRetryAt: string }) { - const delta = new Date(nextRetryAt).getTime() - Date.now(); + // Tick every second so the countdown updates smoothly, independent of the 5s + // data refetch (which otherwise makes it jump in ~5s steps). + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + const delta = new Date(nextRetryAt).getTime() - now; if (delta <= 0) return soon; const s = Math.ceil(delta / 1000); if (s < 60) return in {s}s; From fff5b54bedb1ed59195f906ca3499d8066366c25 Mon Sep 17 00:00:00 2001 From: murugu21 Date: Sun, 14 Jun 2026 14:10:40 +0530 Subject: [PATCH 2/3] feat: email notification when the scale-to-zero box is woken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the wake Lambda actually starts the box (the 0→1 transition), it publishes to an SNS topic subscribed by murugu2001@gmail.com — one email per cold start, naming who woke it, so startups can be monitored. - CDK: SNS topic + email subscription (notifyEmail context, default murugu2001@gmail.com) + sns:Publish grant + TOPIC_ARN on the wake Lambda. - Wake Lambda: handler(event) reads the user email from the request body (falls back to JWT claims) and publishes only on the real start; a notify failure never fails the wake. - FE: extract `email` from the Google ID token at login (jwt.emailFromIdToken), store it, and send it in the requestWake() body. Requires a ComputeStack redeploy + a one-time confirmation of the SNS email subscription. Until the FE is deployed, the email falls back to the JWT sub. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/api.ts | 9 +++- apps/web/src/lib/jwt.ts | 6 +++ apps/web/src/pages/auth-callback.tsx | 16 ++++++- infra/cdk/cdk.context.json | 66 ---------------------------- infra/cdk/lambda/wake/index.mjs | 48 +++++++++++++++++--- infra/cdk/lib/compute-stack.ts | 17 +++++++ 6 files changed, 86 insertions(+), 76 deletions(-) delete mode 100644 infra/cdk/cdk.context.json diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index de8405d..9296293 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -3,7 +3,7 @@ import type { Form, FormWithFields, PublicForm, Submission, SubmissionWithForm, } from "./types"; import { refreshTokens } from "./pkce"; -import { getAccessToken, getRefreshToken, storeTokens } from "@/pages/auth-callback"; +import { getAccessToken, getRefreshToken, storeTokens, getUserEmail } from "@/pages/auth-callback"; const API_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; const AUTH_MODE: string = import.meta.env.VITE_AUTH_MODE ?? "dev"; @@ -62,7 +62,12 @@ export async function requestWake(): Promise { const authHeader = getAuthHeader(); if (!authHeader) return; try { - await fetch(WAKE_URL, { method: "POST", headers: { authorization: authHeader } }); + await fetch(WAKE_URL, { + method: "POST", + headers: { authorization: authHeader, "content-type": "application/json" }, + // Email (from the ID token) so the owner is notified who woke the box. + body: JSON.stringify({ email: getUserEmail() ?? undefined }), + }); } catch { /* best effort */ } diff --git a/apps/web/src/lib/jwt.ts b/apps/web/src/lib/jwt.ts index 8f43b01..56e8393 100644 --- a/apps/web/src/lib/jwt.ts +++ b/apps/web/src/lib/jwt.ts @@ -37,3 +37,9 @@ export function displayNameFromIdToken(idToken: string): string | null { } return null; } + +/** The user's email claim from an OIDC ID token, or null. */ +export function emailFromIdToken(idToken: string): string | null { + const email = decodeJwtPayload(idToken)?.email; + return typeof email === "string" && email.trim().length > 0 ? email.trim() : null; +} diff --git a/apps/web/src/pages/auth-callback.tsx b/apps/web/src/pages/auth-callback.tsx index 512c6db..8a1bac5 100644 --- a/apps/web/src/pages/auth-callback.tsx +++ b/apps/web/src/pages/auth-callback.tsx @@ -4,7 +4,7 @@ import { exchangeCode } from "@/lib/pkce"; import type { CognitoConfig } from "@/lib/pkce"; import { api } from "@/lib/api"; import { useAuth } from "@/lib/auth"; -import { displayNameFromIdToken } from "@/lib/jwt"; +import { displayNameFromIdToken, emailFromIdToken } from "@/lib/jwt"; function getCognitoCfg(): CognitoConfig { return { @@ -18,11 +18,17 @@ function getCognitoCfg(): CognitoConfig { const ACCESS_TOKEN_KEY = "eventform.accessToken"; const REFRESH_TOKEN_KEY = "eventform.refreshToken"; +const EMAIL_KEY = "eventform.email"; export function getAccessToken(): string | null { return localStorage.getItem(ACCESS_TOKEN_KEY); } +/** The signed-in user's email (from the ID token), for the wake notification. */ +export function getUserEmail(): string | null { + return localStorage.getItem(EMAIL_KEY); +} + export function getRefreshToken(): string | null { return localStorage.getItem(REFRESH_TOKEN_KEY); } @@ -37,6 +43,7 @@ export function storeTokens(accessToken: string, refreshToken?: string): void { export function clearTokens(): void { localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(EMAIL_KEY); } export default function AuthCallbackPage() { @@ -75,6 +82,13 @@ export default function AuthCallbackPage() { if (displayName) { await api.updateMe(displayName).catch(() => undefined); } + // Stash the email (from the ID token) for the scale-to-zero wake + // notification — requestWake() sends it so the owner is emailed who + // started the box. Best-effort; sign-in must not depend on it. + const email = emailFromIdToken(tokens.id_token); + if (email) { + localStorage.setItem(EMAIL_KEY, email); + } // Re-sync the auth context BEFORE navigating — RequireAuth otherwise // still holds the at-mount null sub and bounces back to /login. completeSignIn(); diff --git a/infra/cdk/cdk.context.json b/infra/cdk/cdk.context.json deleted file mode 100644 index d0ba91a..0000000 --- a/infra/cdk/cdk.context.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "vpc-provider:account=536972289919:filter.isDefault=true:region=eu-west-2:returnAsymmetricSubnets=true": { - "vpcId": "vpc-0532c9aff15bab055", - "vpcCidrBlock": "172.31.0.0/16", - "ownerAccountId": "536972289919", - "availabilityZones": [], - "subnetGroups": [ - { - "name": "Public", - "type": "Public", - "subnets": [ - { - "subnetId": "subnet-01c0e30a04d01bcc0", - "cidr": "172.31.16.0/20", - "availabilityZone": "eu-west-2a", - "routeTableId": "rtb-0df448a091bfb2c7b" - }, - { - "subnetId": "subnet-0f508483bec4f14e5", - "cidr": "172.31.32.0/20", - "availabilityZone": "eu-west-2b", - "routeTableId": "rtb-0df448a091bfb2c7b" - }, - { - "subnetId": "subnet-04844df3545c6aecb", - "cidr": "172.31.0.0/20", - "availabilityZone": "eu-west-2c", - "routeTableId": "rtb-0df448a091bfb2c7b" - } - ] - } - ] - }, - "vpc-provider:account=536972289919:filter.isDefault=true:region=ap-south-1:returnAsymmetricSubnets=true": { - "vpcId": "vpc-0c6cf65d84b82a02d", - "vpcCidrBlock": "172.31.0.0/16", - "ownerAccountId": "536972289919", - "availabilityZones": [], - "subnetGroups": [ - { - "name": "Public", - "type": "Public", - "subnets": [ - { - "subnetId": "subnet-01e2fb1025d1f6de6", - "cidr": "172.31.32.0/20", - "availabilityZone": "ap-south-1a", - "routeTableId": "rtb-06232339fc3ebdc0c" - }, - { - "subnetId": "subnet-0cfb97198de813c29", - "cidr": "172.31.0.0/20", - "availabilityZone": "ap-south-1b", - "routeTableId": "rtb-06232339fc3ebdc0c" - }, - { - "subnetId": "subnet-0f11890f8370f432d", - "cidr": "172.31.16.0/20", - "availabilityZone": "ap-south-1c", - "routeTableId": "rtb-06232339fc3ebdc0c" - } - ] - } - ] - } -} diff --git a/infra/cdk/lambda/wake/index.mjs b/infra/cdk/lambda/wake/index.mjs index 56763e8..14c9d28 100644 --- a/infra/cdk/lambda/wake/index.mjs +++ b/infra/cdk/lambda/wake/index.mjs @@ -8,29 +8,63 @@ import { DescribeAutoScalingGroupsCommand, SetDesiredCapacityCommand, } from "@aws-sdk/client-auto-scaling"; +import { SNSClient, PublishCommand } from "@aws-sdk/client-sns"; import { decideWake } from "./decide.mjs"; const ASG_NAME = process.env.ASG_NAME; -const client = new AutoScalingClient({}); +const TOPIC_ARN = process.env.TOPIC_ARN; +const asg = new AutoScalingClient({}); +const sns = new SNSClient({}); -export async function handler() { +export async function handler(event) { if (!ASG_NAME) return json(500, { error: "ASG_NAME not configured" }); - const out = await client.send( + const out = await asg.send( new DescribeAutoScalingGroupsCommand({ AutoScalingGroupNames: [ASG_NAME] }), ); - const asg = out.AutoScalingGroups?.[0]; - if (!asg) return json(404, { error: "auto scaling group not found" }); + const group = out.AutoScalingGroups?.[0]; + if (!group) return json(404, { error: "auto scaling group not found" }); - const decision = decideWake(asg); + const decision = decideWake(group); if (decision.action === "start") { - await client.send( + await asg.send( new SetDesiredCapacityCommand({ AutoScalingGroupName: ASG_NAME, DesiredCapacity: 1 }), ); + // Notify only on the real 0→1 transition — one email per cold start, not per + // wake call (concurrent callers during boot get a no-op). Never fail the wake + // on a notify error. + await notifyStarted(event); } return json(200, { state: decision.state, desired: decision.desired }); } +async function notifyStarted(event) { + if (!TOPIC_ARN) return; + const who = userFrom(event); + try { + await sns.send( + new PublishCommand({ + TopicArn: TOPIC_ARN, + Subject: `EventForm started by ${who}`.slice(0, 99), + Message: `EventForm backend was woken from idle.\n\nUser: ${who}\nWhen: ${new Date().toISOString()}`, + }), + ); + } catch (e) { + console.error("wake notify failed:", e?.message); + } +} + +function userFrom(event) { + try { + const body = event?.body ? JSON.parse(event.body) : {}; + if (body?.email) return String(body.email); + } catch { + /* malformed body — fall through to claims */ + } + const claims = event?.requestContext?.authorizer?.jwt?.claims ?? {}; + return claims.email || claims.username || claims.sub || "an authenticated user"; +} + function json(statusCode, body) { return { statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }; } diff --git a/infra/cdk/lib/compute-stack.ts b/infra/cdk/lib/compute-stack.ts index 3a50bdb..e91e4c1 100644 --- a/infra/cdk/lib/compute-stack.ts +++ b/infra/cdk/lib/compute-stack.ts @@ -5,6 +5,8 @@ import * as autoscaling from "aws-cdk-lib/aws-autoscaling"; import * as iam from "aws-cdk-lib/aws-iam"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as sns from "aws-cdk-lib/aws-sns"; +import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import { HttpJwtAuthorizer } from "aws-cdk-lib/aws-apigatewayv2-authorizers"; import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations"; @@ -271,6 +273,21 @@ export class ComputeStack extends cdk.Stack { }), ); + // Notify on a real cold start (0→1). SNS email subscription → you get one + // email per startup naming who woke it. The subscriber must confirm the + // one-time "confirm subscription" email AWS sends. Pass `-c notifyEmail=` + // (empty) to disable. + const notifyEmail = + (this.node.tryGetContext("notifyEmail") as string | undefined) ?? "murugu2001@gmail.com"; + if (notifyEmail) { + const notifyTopic = new sns.Topic(this, "WakeNotifyTopic", { + displayName: "EventForm backend started", + }); + notifyTopic.addSubscription(new subscriptions.EmailSubscription(notifyEmail)); + notifyTopic.grantPublish(wakeFn); + wakeFn.addEnvironment("TOPIC_ARN", notifyTopic.topicArn); + } + const wakeApi = new apigwv2.HttpApi(this, "WakeApi", { description: "EventForm wake endpoint (Cognito-authorized)", corsPreflight: { From 850f578df483dbd8f861c2cf63c54a0c25b883f6 Mon Sep 17 00:00:00 2001 From: murugu21 Date: Sun, 14 Jun 2026 14:24:05 +0530 Subject: [PATCH 3/3] chore(cdk): gitignore cdk.context.json (regenerated per-env lookup cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the VPC/AMI/account-ID lookup cache out of git — it regenerates on each synth/deploy and kept resurfacing as an untracked file (and blocking rebases). Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/cdk/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/infra/cdk/.gitignore b/infra/cdk/.gitignore index 850b823..888601e 100644 --- a/infra/cdk/.gitignore +++ b/infra/cdk/.gitignore @@ -1,6 +1,9 @@ node_modules/ dist/ cdk.out/ +# Per-environment lookup cache (VPC/AMI/account IDs) — regenerated on each +# synth/deploy; not committed (avoids churn + keeping account/VPC IDs in a public repo). +cdk.context.json *.d.ts *.js *.js.map