Skip to content

Commit e9b72c8

Browse files
authored
Membership cron job (#1675)
* edit membership renewl cron job and email template * add cron job * add aws backup * updates
1 parent 120049a commit e9b72c8

7 files changed

Lines changed: 291 additions & 127 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
types: ["opened", "synchronize", "reopened", "edited"]
88
workflow_dispatch:
99

10+
permissions:
11+
contents: read
12+
1013
jobs:
1114
format:
1215
name: Run Prettier
@@ -79,3 +82,24 @@ jobs:
7982

8083
- name: Run tsc
8184
run: pnpm typecheck
85+
86+
backup-db:
87+
name: Backup database to S3
88+
runs-on: ubuntu-latest
89+
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
90+
steps:
91+
- name: Dump database
92+
env:
93+
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
94+
PGSSLMODE: require
95+
run: pg_dump "$SUPABASE_DB_URL" -Fc -f backup.dump
96+
97+
- name: Upload to S3
98+
env:
99+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
100+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
101+
AWS_REGION: ${{ secrets.AWS_REGION }}
102+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
103+
run: |
104+
aws s3 cp backup.dump "s3://$S3_BUCKET/backups/$(date +%Y-%m-%d_%H-%M-%S).dump"
105+
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Performance Report — codersforcauses.org
2+
3+
> Generated: 2026-02-26
4+
> Tool: Chrome DevTools MCP (no CPU/network throttling, lab conditions)
5+
> No CrUX field data available for this site.
6+
7+
---
8+
9+
## Summary
10+
11+
| Page | LCP | CLS | Status |
12+
| ------------------------------------ | -------- | -------- | ------------- |
13+
| Homepage (`/`) | 463 ms | 0.00 | Good |
14+
| User Dashboard (`/dashboard`) | 463 ms\* | 0.00\* | Good |
15+
| Admin Dashboard (`/dashboard/admin`) | 827 ms | **0.25** | CLS needs fix |
16+
17+
> \* Dashboard page not independently traced — estimated similar to homepage.
18+
19+
---
20+
21+
## Page 1: Homepage (`/`)
22+
23+
### Core Web Vitals
24+
25+
| Metric | Score | Threshold |
26+
| ------ | ------ | --------------- |
27+
| LCP | 463 ms | Good (<2,500ms) |
28+
| CLS | 0.00 | Good (<0.1) |
29+
30+
### LCP Breakdown
31+
32+
The LCP element is a `<p>` text node — no network fetch needed.
33+
34+
| Phase | Time | % of LCP |
35+
| -------------------- | ---------- | --------- |
36+
| TTFB | 53 ms | 11.5% |
37+
| Element Render Delay | **410 ms** | **88.5%** |
38+
39+
The large render delay is caused by Next.js JS hydration blocking text rendering before it becomes the LCP element.
40+
41+
### Issues
42+
43+
#### 1. JS Hydration Render Delay (410ms)
44+
45+
LCP text is not painted until after JS hydration completes.
46+
47+
**Fix:** Ensure the LCP text is present in the initial SSR HTML and is not dependent on client-side JS to render.
48+
49+
#### 2. Network Dependency Chain (critical path: 567ms)
50+
51+
```
52+
HTML → CSS (a25ca756ef64d4e8.css, 400ms)
53+
└── Font (material-symbols-sharp.fcbbf02a.woff2, 411ms)
54+
```
55+
56+
The font is only discovered after CSS is parsed. No `preconnect` hints configured.
57+
58+
**Fix:** Add a preload hint in `<head>` to break the chain:
59+
60+
```html
61+
<link
62+
rel="preload"
63+
href="/_next/static/media/material-symbols-sharp.fcbbf02a.woff2"
64+
as="font"
65+
type="font/woff2"
66+
crossorigin
67+
/>
68+
```
69+
70+
#### 3. Unsized Client Logo Images (CLS: 0.0038 — minor but recurring)
71+
72+
The logo carousel triggers 34 micro layout shifts (589ms–4,134ms) from unsized `<img>` elements:
73+
74+
| Image | Shifts |
75+
| --------------------- | ------ |
76+
| `foodbank_logo.svg` | 1 |
77+
| `csf_logo_dark.svg` | 26 |
78+
| `repair_lab_logo.png` | 7 |
79+
80+
**Fix:** Add explicit `width` and `height` attributes to carousel `<img>` tags:
81+
82+
```html
83+
<img
84+
src="/clients/csf_logo_dark.svg"
85+
width="160"
86+
height="60"
87+
class="brightness-110 contrast-[0.2] grayscale transition duration-300"
88+
/>
89+
```
90+
91+
Or use CSS `aspect-ratio` to reserve space before images load.
92+
93+
---
94+
95+
## Page 2: Admin Dashboard (`/dashboard/admin`)
96+
97+
### Core Web Vitals
98+
99+
| Metric | Score | Threshold |
100+
| ------ | -------- | ------------------------------- |
101+
| LCP | 827 ms | Good (<2,500ms) |
102+
| CLS | **0.25** | **Bad (>0.1, at Bad boundary)** |
103+
104+
### LCP Breakdown
105+
106+
The LCP element is the footer logo `cfc_logo_white_full.svg` — unusual, as the actual page content (user data table) is not the LCP element, suggesting the table content is rendered client-side.
107+
108+
| Phase | Time | % of LCP |
109+
| ---------------------- | ---------- | --------- |
110+
| TTFB | 55 ms | 6.6% |
111+
| Resource Load Delay | **667 ms** | **80.6%** |
112+
| Resource Load Duration | 57 ms | 6.9% |
113+
| Element Render Delay | 49 ms | 5.9% |
114+
115+
### Issues
116+
117+
#### 1. CLS: 0.25 — CRITICAL
118+
119+
A single layout shift of score **0.2482** at 1,308ms dominates. Root cause: the footer logo `cfc_logo_white_full.svg` has no `width`/`height` attributes.
120+
121+
**Fix:** Add explicit dimensions to the footer logo:
122+
123+
```html
124+
<!-- In the footer component -->
125+
<img
126+
src="/logo/cfc_logo_white_full.svg"
127+
class="!w-auto object-top md:!h-auto"
128+
width="200"
129+
height="50"
130+
alt="Coders for Causes logo"
131+
/>
132+
```
133+
134+
#### 2. LCP Image: Missing `fetchpriority=high` + Lazy Loading Applied
135+
136+
The LCP image (`cfc_logo_white_full.svg`) failed two discovery checks:
137+
138+
- `fetchpriority=high` not set
139+
- `loading="lazy"` is applied (delays the LCP image)
140+
141+
**Fix:**
142+
143+
```html
144+
<img
145+
src="/logo/cfc_logo_white_full.svg"
146+
fetchpriority="high"
147+
loading="eager" <!-- remove lazy loading from LCP images -->
148+
width="200"
149+
height="50"
150+
/>
151+
```
152+
153+
#### 3. LCP Resource Load Delay (667ms — 80.6% of LCP)
154+
155+
The logo image isn't requested until 722ms after navigation starts. The browser can't discover it until late in page load.
156+
157+
**Fix:** Add a `preload` hint for the logo if it consistently appears as the LCP element:
158+
159+
```html
160+
<link rel="preload" href="/logo/cfc_logo_white_full.svg" as="image" />
161+
```
162+
163+
#### 4. Aggressive Cache Revalidation on Static Asset
164+
165+
The logo SVG returns `cache-control: public, max-age=0, must-revalidate`, meaning every page load triggers a revalidation request. The 304 response still adds latency.
166+
167+
**Fix:** For static/versioned assets, use long-lived caching:
168+
169+
```
170+
cache-control: public, max-age=31536000, immutable
171+
```
172+
173+
Since this is deployed on Vercel, configure this in `next.config.js` or `vercel.json`.
174+
175+
#### 5. Network Dependency Chain (critical path: 1,123ms)
176+
177+
```
178+
HTML → CSS (a25ca756ef64d4e8.css, 717ms)
179+
└── Font (material-symbols-sharp.fcbbf02a.woff2, 786ms)
180+
```
181+
182+
Same font chain issue as the homepage — no `preconnect` or `preload` hints.
183+
184+
---
185+
186+
## Top Priorities (All Pages)
187+
188+
| Priority | Issue | Page(s) | Impact |
189+
| -------- | ------------------------------------------- | --------------- | ------------- |
190+
| 1 | CLS 0.25 from unsized footer logo | Admin Dashboard | CLS (Bad) |
191+
| 2 | Remove `loading="lazy"` from LCP image | Admin Dashboard | LCP |
192+
| 3 | Add `fetchpriority="high"` to LCP image | Admin Dashboard | LCP |
193+
| 4 | Preload `material-symbols-sharp.woff2` font | All pages | LCP / FCP |
194+
| 5 | Fix LCP text render delay (JS hydration) | Homepage | LCP |
195+
| 6 | Add `width`/`height` to carousel logos | Homepage | CLS stability |
196+
| 7 | Fix logo `cache-control` to long-lived | Admin Dashboard | Load speed |
Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import * as Sentry from "@sentry/nextjs"
2-
import { eq } from "drizzle-orm"
2+
import { format } from "date-fns"
3+
import { lte } from "drizzle-orm"
34
import type { NextRequest } from "next/server"
5+
import { Resend } from "resend"
6+
7+
import { MembershipRenewalReminderEmail } from "~/components/email-template"
48

59
import { env } from "~/env"
610
import { db } from "~/server/db"
711
import { User } from "~/server/db/schema"
812

913
export const dynamic = "force-dynamic"
14+
const resend = new Resend(process.env.RESEND_API_KEY)
1015

1116
export async function GET(request: NextRequest) {
1217
const authHeader = request.headers.get("authorization")
@@ -17,23 +22,37 @@ export async function GET(request: NextRequest) {
1722
}
1823

1924
let dbRes: (typeof User.$inferSelect)[] = []
25+
const today = new Date()
2026

2127
await Sentry.withMonitor("cycle-memberships", async () => {
22-
// TODO backup with xata cli and put into aws bucket
23-
await db.update(User).set({ reminder_pending: true }).where(eq(User.role, "member"))
24-
dbRes = await db.update(User).set({ role: null }).where(eq(User.role, "member")).returning()
25-
console.log(dbRes.length)
28+
dbRes = await db
29+
.update(User)
30+
.set({ role: null, membership_expiry: null })
31+
.where(lte(User.membership_expiry, today))
32+
.returning()
2633
})
2734

28-
if (!dbRes.length) {
29-
return new Response("Internal Server Error", {
30-
status: 500,
31-
})
35+
for (const user of dbRes) {
36+
try {
37+
const formattedToday = format(today, "dd MMM yyyy")
38+
await resend.emails.send({
39+
from: "Coders for Causes <noreply@codersforcauses.org>",
40+
to: user.email,
41+
subject: "Reminder of your membership renewal",
42+
react: MembershipRenewalReminderEmail({
43+
firstname: user.preferred_name,
44+
membershipEndDate: formattedToday,
45+
}),
46+
})
47+
} catch (err) {
48+
console.error(`Failed to send email to ${user.email}`, err)
49+
Sentry.captureException(err, { extra: { memberId: user.id } })
50+
}
3251
}
3352

3453
return Response.json({
3554
success: true,
36-
message: `Memberships for ${new Date().getFullYear()} have been cycled.`,
55+
message: `${dbRes.length} Memberships for ${today.toISOString().slice(0, 10)} have been cycled.`,
3756
count: dbRes.length,
3857
})
3958
}

src/app/api/cron/membership-renewal-reminder/route.ts

Lines changed: 0 additions & 86 deletions
This file was deleted.

0 commit comments

Comments
 (0)