Skip to content

Commit 3693fbe

Browse files
committed
Sprint 3: Polar payments + lifecycle emails + storage purge
Polar end-to-end (lights up when you paste credentials tomorrow): - lib/polar.ts: client, product slug → tier mapping, isPolarConfigured() - /api/polar/checkout: creates a checkout URL for the signed-in user; passes externalCustomerId=user.id and metadata={user_id, tier} - /api/polar/webhook: standardwebhooks signature verification; handles subscription.{created,updated,active,canceled,revoked} and order.{created,paid} (Lifetime). Upserts subscriptions row with provider, provider_customer_id, provider_subscription_id, tier, status, current_period_end, cancel_at_period_end. - /api/polar/portal: creates a customer portal session by externalCustomerId - components/pricing-cta.tsx + manage-subscription-button.tsx: client-side wrappers that call the routes above - /pricing: CTAs route to checkout when Polar is configured, fall back to "Coming soon" when env is missing; unauth users get redirected to /sign-in?next=/pricing first - /dashboard: tier badge + "Manage subscription" button for paying users Lifecycle emails (cron-driven, retention engine): - lib/lifecycle.ts: Day 0/1/3/7/14/30 + 60-day winback templates, pickStage() picks the right one given days-since-signup + days-since-last-analysis - /api/cron/lifecycle-emails: pulls from public.user_lifecycle view, dedupes via lifecycle_sends table, sends via Resend - .github/workflows/cron-lifecycle-emails.yml: daily 14:00 UTC Storage purge: - /api/cron/storage-purge: deletes Storage objects whose documents.purge_at is in the past (status in ready/failed). Marks rows status=purged. - analyze route now sets purge_at to T+1 day for free tier and T+30 days for paid tier so the cron recovers free-tier space quickly. - .github/workflows/cron-storage-purge.yml: daily 03:30 UTC Email contacts migrated to @iammara.com: - lib/email.ts default FROM = noreply@iammara.com, replyTo = support@iammara.com - SECURITY.md: security@iammara.com - CODE_OF_CONDUCT.md: conduct@iammara.com - marketing/lifecycle-emails.md: sender persona updated Schema: - lifecycle_sends table (user_id, stage primary key) for dedupe - public.user_lifecycle view: signup-age + last-analysis-age per user Tests: 30 passing (was 19) - lib/__tests__/polar.test.ts: 5 tests for product mapping + isConfigured - lib/__tests__/lifecycle.test.ts: 6 tests for pickStage transitions Local CI green: typecheck, lint, 30 tests, build (15 routes). https://claude.ai/code/session_01W3qcAmw65K9Dagi9keNp8i
1 parent 8f3e112 commit 3693fbe

23 files changed

Lines changed: 919 additions & 46 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: cron-lifecycle-emails
2+
3+
on:
4+
schedule:
5+
- cron: "0 14 * * *" # 14:00 UTC daily — sweet spot for global inbox open rates
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
12+
env:
13+
TARGET_URL: https://printer-olive.vercel.app
14+
15+
jobs:
16+
send:
17+
name: send
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Call lifecycle-emails route
21+
id: call
22+
env:
23+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
24+
run: |
25+
set +e
26+
if [ -z "${CRON_SECRET:-}" ]; then
27+
echo "CRON_SECRET not set; skipping"
28+
echo "skipped=true" >> "$GITHUB_OUTPUT"
29+
exit 0
30+
fi
31+
code=$(curl -sS -o /tmp/out -w '%{http_code}' \
32+
-H "x-cron-secret: $CRON_SECRET" \
33+
"$TARGET_URL/api/cron/lifecycle-emails")
34+
cat /tmp/out
35+
echo
36+
[ "$code" = "200" ] || [ "$code" = "404" ]
37+
- name: Alert on real failure
38+
if: failure() && steps.call.outputs.skipped != 'true'
39+
uses: actions/github-script@v9
40+
with:
41+
script: |
42+
await github.rest.issues.create({
43+
owner: context.repo.owner,
44+
repo: context.repo.repo,
45+
title: "cron-lifecycle-emails failing",
46+
labels: ["incident", "auto", "cron"],
47+
body: `Run: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
48+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: cron-storage-purge
2+
3+
on:
4+
schedule:
5+
- cron: "30 3 * * *" # 03:30 UTC daily
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
12+
env:
13+
TARGET_URL: https://printer-olive.vercel.app
14+
15+
jobs:
16+
purge:
17+
name: purge
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Call storage-purge route
21+
id: call
22+
env:
23+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
24+
run: |
25+
set +e
26+
if [ -z "${CRON_SECRET:-}" ]; then
27+
echo "CRON_SECRET not set; skipping"
28+
echo "skipped=true" >> "$GITHUB_OUTPUT"
29+
exit 0
30+
fi
31+
code=$(curl -sS -o /tmp/out -w '%{http_code}' \
32+
-H "x-cron-secret: $CRON_SECRET" \
33+
"$TARGET_URL/api/cron/storage-purge")
34+
cat /tmp/out
35+
echo
36+
[ "$code" = "200" ] || [ "$code" = "404" ]
37+
- name: Alert on real failure
38+
if: failure() && steps.call.outputs.skipped != 'true'
39+
uses: actions/github-script@v9
40+
with:
41+
script: |
42+
await github.rest.issues.create({
43+
owner: context.repo.owner,
44+
repo: context.repo.repo,
45+
title: "cron-storage-purge failing",
46+
labels: ["incident", "auto", "cron"],
47+
body: `Run: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
48+
});

CODE_OF_CONDUCT.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ identity.
2424

2525
## Enforcement
2626

27-
Reports go to **conduct@paperlens.app** (pre-domain: open a private
28-
GitHub Security Advisory). Maintainers will review and respond within
29-
7 days. Decisions are at the maintainers' discretion and may include
30-
warnings, temporary bans, or permanent bans.
27+
Reports go to **conduct@iammara.com**. Maintainers will review and
28+
respond within 7 days. Decisions are at the maintainers' discretion
29+
and may include warnings, temporary bans, or permanent bans.
3130

3231
The full Contributor Covenant text is at:
3332
https://www.contributor-covenant.org/version/2/1/code_of_conduct/

SECURITY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44

55
Please do **not** open a public GitHub issue for security problems.
66

7-
Instead, email **security@paperlens.app** with:
7+
Instead, email **security@iammara.com** with:
88
- A description of the issue
99
- Steps to reproduce
1010
- The affected version / commit SHA
1111
- Your contact details (so we can credit you, if you want)
1212

1313
If the email above is not yet active (we are still pre-domain), open
1414
a draft GitHub Security Advisory at:
15-
`https://github.com/mara-org/printer/security/advisories/new`
15+
`https://github.com/mara-org/printer/security/advisories/new` — or
16+
use **conduct@iammara.com** as the secondary inbox.
1617

1718
We aim to acknowledge within **72 hours** and to ship a fix within
1819
**14 days** for high-severity issues.

app/api/analyze/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export async function POST(req: Request) {
8484
const mimeType = dl.data.type || "application/pdf";
8585

8686
// 7. Insert a `documents` row (admin client; RLS would also allow it but we already verified ownership).
87+
// Set purge_at tighter for free tier (1 day) than paid (30 days) so the storage-purge cron
88+
// recovers space quickly from abuse / casual one-off uploads.
89+
const purgeDays = quota.tier === "free" ? 1 : 30;
90+
const purgeAt = new Date(Date.now() + purgeDays * 24 * 60 * 60 * 1000).toISOString();
8791
const admin = supabaseAdmin();
8892
const { data: docRow, error: docErr } = await admin
8993
.from("documents")
@@ -94,6 +98,7 @@ export async function POST(req: Request) {
9498
byte_size: arrayBuf.byteLength,
9599
source_locale: body.output_locale,
96100
status: "analyzing",
101+
purge_at: purgeAt,
97102
})
98103
.select("id")
99104
.single();
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { NextResponse } from "next/server";
2+
import { isCronAuthorized } from "@/lib/cron-auth";
3+
import { supabaseAdmin } from "@/lib/supabase-server";
4+
import { pickStage, sendLifecycle } from "@/lib/lifecycle";
5+
6+
export const runtime = "nodejs";
7+
export const dynamic = "force-dynamic";
8+
9+
const BATCH_LIMIT = 100;
10+
11+
export async function GET(req: Request) {
12+
if (!isCronAuthorized(req)) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
13+
if (!process.env.RESEND_API_KEY) {
14+
return NextResponse.json({ ok: true, skipped: "no_resend_key" });
15+
}
16+
17+
const sb = supabaseAdmin();
18+
19+
// Pull a batch of users likely to need a lifecycle email.
20+
// The view handles the date math; we send at most one email per user per cron run.
21+
const { data: rows, error } = await sb
22+
.from("user_lifecycle")
23+
.select("user_id, email, days_since_signup, days_since_last_analysis")
24+
.not("email", "is", null)
25+
.lte("days_since_signup", 90)
26+
.limit(BATCH_LIMIT);
27+
28+
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
29+
if (!rows || rows.length === 0) {
30+
return NextResponse.json({ ok: true, sent: 0, considered: 0 });
31+
}
32+
33+
const userIds = rows.map((r) => r.user_id).filter((v): v is string => typeof v === "string");
34+
const { data: alreadySent } = await sb
35+
.from("lifecycle_sends")
36+
.select("user_id, stage")
37+
.in("user_id", userIds);
38+
39+
const sentSet = new Set((alreadySent ?? []).map((r) => `${r.user_id}:${r.stage}`));
40+
41+
let sent = 0;
42+
let skipped = 0;
43+
44+
for (const r of rows) {
45+
if (!r.user_id || !r.email) {
46+
skipped++;
47+
continue;
48+
}
49+
const stage = pickStage(
50+
r.days_since_signup ?? 0,
51+
r.days_since_last_analysis === null ? null : (r.days_since_last_analysis ?? null),
52+
);
53+
if (!stage) {
54+
skipped++;
55+
continue;
56+
}
57+
const key = `${r.user_id}:${stage}`;
58+
if (sentSet.has(key)) {
59+
skipped++;
60+
continue;
61+
}
62+
63+
const ok = await sendLifecycle(r.email, stage);
64+
if (!ok) continue;
65+
66+
await sb.from("lifecycle_sends").upsert({ user_id: r.user_id, stage, sent_at: new Date().toISOString() });
67+
sent++;
68+
}
69+
70+
return NextResponse.json({ ok: true, sent, considered: rows.length, skipped });
71+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextResponse } from "next/server";
2+
import { isCronAuthorized } from "@/lib/cron-auth";
3+
import { supabaseAdmin } from "@/lib/supabase-server";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
8+
const BATCH = 200;
9+
10+
export async function GET(req: Request) {
11+
if (!isCronAuthorized(req)) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
12+
13+
const sb = supabaseAdmin();
14+
const now = new Date().toISOString();
15+
16+
// Documents whose purge_at is in the past and that have an analysis row OR failed status.
17+
// Keep documents that are still pending (not yet analyzed) so we don't delete in-flight uploads.
18+
const { data: docs, error } = await sb
19+
.from("documents")
20+
.select("id, storage_path, status")
21+
.lte("purge_at", now)
22+
.in("status", ["ready", "failed"])
23+
.limit(BATCH);
24+
25+
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
26+
if (!docs || docs.length === 0) return NextResponse.json({ ok: true, deleted: 0 });
27+
28+
const paths = docs.map((d) => d.storage_path).filter((p): p is string => typeof p === "string");
29+
let storageRemoved = 0;
30+
if (paths.length > 0) {
31+
const { data: removed } = await sb.storage.from("documents").remove(paths);
32+
storageRemoved = removed?.length ?? 0;
33+
}
34+
35+
// Mark rows purged (don't delete; we keep the analyses row for user history).
36+
const ids = docs.map((d) => d.id);
37+
await sb.from("documents").update({ status: "purged", storage_path: "" }).in("id", ids);
38+
39+
return NextResponse.json({ ok: true, considered: docs.length, storageRemoved });
40+
}

app/api/polar/checkout/route.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { NextResponse } from "next/server";
2+
import { z } from "zod";
3+
import { supabaseServer } from "@/lib/supabase-server";
4+
import { getProductId, isPolarConfigured, polarClient } from "@/lib/polar";
5+
6+
export const runtime = "nodejs";
7+
export const dynamic = "force-dynamic";
8+
9+
const Body = z.object({
10+
product: z.enum(["pro", "power", "lifetime"]),
11+
});
12+
13+
export async function POST(req: Request) {
14+
if (!isPolarConfigured()) {
15+
return NextResponse.json({ error: "polar_not_configured" }, { status: 503 });
16+
}
17+
18+
const sb = supabaseServer();
19+
const {
20+
data: { user },
21+
} = await sb.auth.getUser();
22+
if (!user) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
23+
24+
let body: z.infer<typeof Body>;
25+
try {
26+
body = Body.parse(await req.json());
27+
} catch (err) {
28+
return NextResponse.json({ error: "bad_request", detail: (err as Error).message }, { status: 400 });
29+
}
30+
31+
const productId = getProductId(body.product);
32+
if (!productId) {
33+
return NextResponse.json({ error: "product_not_configured" }, { status: 503 });
34+
}
35+
36+
const origin = new URL(req.url).origin;
37+
try {
38+
const checkout = await polarClient().checkouts.create({
39+
products: [productId],
40+
successUrl: `${origin}/dashboard?upgraded=1`,
41+
customerEmail: user.email ?? undefined,
42+
externalCustomerId: user.id,
43+
metadata: { user_id: user.id, tier: body.product },
44+
});
45+
return NextResponse.json({ url: checkout.url });
46+
} catch (err) {
47+
return NextResponse.json(
48+
{ error: "checkout_failed", detail: (err as Error).message },
49+
{ status: 502 },
50+
);
51+
}
52+
}

app/api/polar/portal/route.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextResponse } from "next/server";
2+
import { supabaseServer } from "@/lib/supabase-server";
3+
import { isPolarConfigured, polarClient } from "@/lib/polar";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
8+
export async function POST(req: Request) {
9+
if (!isPolarConfigured()) {
10+
return NextResponse.json({ error: "polar_not_configured" }, { status: 503 });
11+
}
12+
13+
const sb = supabaseServer();
14+
const {
15+
data: { user },
16+
} = await sb.auth.getUser();
17+
if (!user) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
18+
19+
try {
20+
const session = await polarClient().customerSessions.create({
21+
externalCustomerId: user.id,
22+
});
23+
return NextResponse.json({ url: session.customerPortalUrl });
24+
} catch (err) {
25+
return NextResponse.json(
26+
{ error: "portal_failed", detail: (err as Error).message },
27+
{ status: 502 },
28+
);
29+
}
30+
}

0 commit comments

Comments
 (0)