Skip to content
Open
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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ CSP_ENFORCE="false"
NODE_ENV="development"
LOG_LEVEL="debug"

# NOTE (stripped launch build): PayMongo and Resend were removedpayments
# are collected manually (GCash/bank transfer) and enrollments granted via
# NOTE (stripped launch build): PayMongo was removed, payments are
# collected manually (GCash/bank transfer) and enrollments granted via
# /admin/enroll. See docs/LAUNCH-DEPLOY.md.

# Email (ADR-007). Sends no-op (logs only) when RESEND_API_KEY is unset.
RESEND_API_KEY=""
RESEND_FROM_EMAIL="noreply@projectamazonph.online"
2 changes: 1 addition & 1 deletion eslint-rules/no-tailwind.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
const TAILWIND_UTILITIES = /\b(bg-|text-|flex|grid|gap-|w-|h-|p-|m-|rounded-|border-|shadow-|font-|leading-|tracking-|overflow-|position-|z-|opacity-|cursor-|select-|sr-|transition-|animate-|from-|to-|via-|dark:|hover:|focus:|active:|disabled:)/;

const PDF_GENERATOR_FILES = /cert-pdf|receipt-pdf/;
const EMAIL_TEMPLATE_FILES = /email\.tsx$/;
const EMAIL_TEMPLATE_FILES = /email\.tsx$|[\\/]emails[\\/]/;

function isTokensFile(filename) {
return filename && filename.includes('src/styles/tokens.css');
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"pino": "^10.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-email": "^6.9.1",
"resend": "^6.18.1",
"server-only": "^0.0.1",
"zod": "^4.4.3"
},
Expand All @@ -55,11 +57,11 @@
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
"dotenv": "^17.4.2",
"pino-pretty": "^13.0.0",
"eslint": "^9.39.5",
"eslint-config-next": "^16.0.0",
"husky": "^9.1.6",
"lint-staged": "^17.0.8",
"pino-pretty": "^13.0.0",
"prettier": "^3.9.5",
"prisma": "^7.8.0",
"tsx": "^4.23.1",
Expand Down
630 changes: 630 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions src/app/actions/admin-enroll.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
'use server';

/**
* Manual enrollment action stripped launch build.
* Manual enrollment action (stripped launch build).
*
* Admin enters a student email + pricing tier; we create/find the user and
* enroll them in every course on the tier. For brand-new students the
* one-time claim link is returned so the admin can send it to the student
* over Messenger/email themselves (no automated email in this build).
* one-time claim link is emailed automatically (best-effort) and also
* returned so the admin can send it themselves over Messenger as a backup
* if the email doesn't land (e.g. no Resend domain verified yet).
*/

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/auth';
import { auditLog } from '@/lib/admin-audit';
import { grantManualEnrollment } from '@/lib/enrollment';
import { sendAccountInviteEmail } from '@/lib/email';
import type { ActionResult } from '@/lib/validation';

const manualEnrollSchema = z.object({
Expand All @@ -24,7 +26,7 @@ const manualEnrollSchema = z.object({

export interface ManualEnrollActionData {
isNewUser: boolean;
/** Full signup link for new accounts — show once, admin sends it manually. */
/** Full signup link for new accounts, shown once, admin sends it manually. */
claimUrl?: string;
tierName: string;
enrolledCount: number;
Expand Down Expand Up @@ -67,6 +69,14 @@ export async function manualEnrollAction(
url.searchParams.set('email', parsed.data.email);
url.searchParams.set('next', '/dashboard');
claimUrl = url.toString();

// Best-effort: errors are logged, never thrown. The claimUrl above is
// shown to the admin regardless, as a manual-send backup.
sendAccountInviteEmail({
to: parsed.data.email,
tierName: result.tierName,
claimUrl,
}).catch(() => {});
}

return {
Expand Down
6 changes: 6 additions & 0 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getSession,
} from '@/lib/auth';
import { logger } from '@/lib/logger';
import { sendWelcomeEmail } from '@/lib/email';
import { rateLimit } from '@/lib/rate-limit';
import {
hashClaimToken,
Expand Down Expand Up @@ -93,6 +94,9 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => {
name: data.name ?? existing.name,
});
await setAuthCookie(token);

sendWelcomeEmail({ to: existing.email, studentName: data.name ?? existing.name ?? 'there' }).catch(() => {});

return { userId: existing.id };
}

Expand All @@ -115,6 +119,8 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => {
});
await setAuthCookie(token);

sendWelcomeEmail({ to: user.email, studentName: user.name ?? 'there' }).catch(() => {});

return { userId: user.id };
});

Expand Down
10 changes: 10 additions & 0 deletions src/app/actions/certificates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { createSafeAction } from '@/lib/validation';
import { evaluateCourseAccess } from '@/lib/tier-gate';
import { sendCertificateIssuedEmail } from '@/lib/email';
import {
issueCertificate,
getCertificateByVerificationHash,
Expand Down Expand Up @@ -61,6 +62,15 @@ export const issueCertificateAction = createSafeAction<
);
}

if (!issued.alreadyExisted) {
sendCertificateIssuedEmail({
to: user.email,
studentName: user.name ?? 'there',
courseTitle: course.title,
verificationHash: issued.verificationHash,
}).catch(() => {});
}

return {
certificateId: issued.id,
verificationHash: issued.verificationHash,
Expand Down
30 changes: 30 additions & 0 deletions src/emails/AccountInviteEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { EmailButton, EmailFootnote, EmailHeading, EmailParagraph, EmailShell } from './shared';

export interface AccountInviteEmailProps {
tierName: string;
claimUrl: string;
}

/**
* Sent when the admin manually enrolls a brand-new student (/admin/enroll).
* Delivers the single-use link the student uses to set a password and claim
* their account. This is the only place the raw claim token should be sent,
* never log it. The link expires after CLAIM_TOKEN_TTL_MS (7 days).
*/
export default function AccountInviteEmail({ tierName, claimUrl }: AccountInviteEmailProps) {
return (
<EmailShell previewText={`Set your password to access ${tierName}`}>
<EmailHeading>Set your password to finish</EmailHeading>
<EmailParagraph>
You&apos;ve been enrolled in <strong>{tierName}</strong>. To access your
account, set a password using the secure link below. It expires in 7
days.
</EmailParagraph>
<EmailButton href={claimUrl}>Claim your account →</EmailButton>
<EmailFootnote>
If you weren&apos;t expecting this, you can ignore this email. No
account can be accessed without this link.
</EmailFootnote>
</EmailShell>
);
}
33 changes: 33 additions & 0 deletions src/emails/CertificateIssuedEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { EmailButton, EmailFootnote, EmailHeading, EmailParagraph, EmailShell } from './shared';

export interface CertificateIssuedEmailProps {
studentName: string;
courseTitle: string;
certificateUrl: string;
verifyUrl: string;
}

/** Sent when a student earns a course-completion certificate. */
export default function CertificateIssuedEmail({
studentName,
courseTitle,
certificateUrl,
verifyUrl,
}: CertificateIssuedEmailProps) {
return (
<EmailShell previewText={`Certificate earned: ${courseTitle}`}>
<EmailHeading>Congratulations, {studentName}!</EmailHeading>
<EmailParagraph>
You&apos;ve completed <strong>{courseTitle}</strong> and earned your
certificate.
</EmailParagraph>
<EmailButton href={certificateUrl}>View your certificate →</EmailButton>
<EmailFootnote>
Anyone can verify this certificate at{' '}
<a href={verifyUrl} style={{ color: '#737373' }}>
{verifyUrl}
</a>
</EmailFootnote>
</EmailShell>
);
}
57 changes: 57 additions & 0 deletions src/emails/LiveClassConfirmationEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
EmailButton,
EmailDetailBox,
EmailDetailRow,
EmailFootnote,
EmailHeading,
EmailParagraph,
EmailShell,
} from './shared';

export interface LiveClassConfirmationEmailProps {
studentName: string;
classTitle: string;
instructorName: string;
date: string;
time: string;
durationMinutes: number;
meetingUrl?: string | null;
classesUrl: string;
}

/** Sent immediately when a student registers for a live class. */
export default function LiveClassConfirmationEmail({
studentName,
classTitle,
instructorName,
date,
time,
durationMinutes,
meetingUrl,
classesUrl,
}: LiveClassConfirmationEmailProps) {
return (
<EmailShell
previewText={`You're registered for ${classTitle} (${date})`}
eyebrow="Project Amazon PH Academy · Live Class"
>
<EmailHeading>{classTitle}</EmailHeading>
<EmailParagraph style={{ color: '#FF6B35', fontWeight: 600, margin: '-8px 0 20px' }}>
with {instructorName}
</EmailParagraph>
<EmailParagraph>
Hi {studentName}, you&apos;re registered for this live class.
</EmailParagraph>
<EmailDetailBox>
<EmailDetailRow label="When" value={`${date} · ${time}`} />
<EmailDetailRow label="Duration" value={`${durationMinutes} minutes`} />
</EmailDetailBox>
<EmailButton href={meetingUrl || classesUrl}>
{meetingUrl ? 'Join the class →' : 'View class details →'}
</EmailButton>
<EmailFootnote>
A recording will be available afterward if you can&apos;t make it live.
</EmailFootnote>
</EmailShell>
);
}
30 changes: 30 additions & 0 deletions src/emails/PasswordResetEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { EmailButton, EmailFootnote, EmailHeading, EmailParagraph, EmailShell } from './shared';

export interface PasswordResetEmailProps {
resetUrl: string;
expiresInMinutes: number;
}

/**
* Password-reset link. Template only, no reset-token flow exists yet
* (there is no forgot-password action, token model, or reset page in this
* build; see the sign-in note in src/app/actions/auth.ts about the prior
* emailVerified lockout incident). Wire this up alongside that flow, not
* before it. Don't gate anything on delivery until send and verify both work.
*/
export default function PasswordResetEmail({ resetUrl, expiresInMinutes }: PasswordResetEmailProps) {
return (
<EmailShell previewText="Reset your password">
<EmailHeading>Reset your password</EmailHeading>
<EmailParagraph>
We received a request to reset your password. Click below to choose a
new one. This link expires in {expiresInMinutes} minutes.
</EmailParagraph>
<EmailButton href={resetUrl}>Reset password →</EmailButton>
<EmailFootnote>
If you didn&apos;t request this, you can safely ignore this email.
Your password won&apos;t change.
</EmailFootnote>
</EmailShell>
);
}
27 changes: 27 additions & 0 deletions src/emails/PaymentFailedEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EmailButton, EmailHeading, EmailParagraph, EmailShell } from './shared';

export interface PaymentFailedEmailProps {
studentName: string;
tierName: string;
retryUrl: string;
}

/**
* Notifies a buyer their payment didn't go through. Not wired to a live
* trigger in this build (depends on the PayMongo checkout flow that was
* stripped for the manual-enrollment launch). Ready to call from a payment
* webhook handler when that flow returns.
*/
export default function PaymentFailedEmail({ studentName, tierName, retryUrl }: PaymentFailedEmailProps) {
return (
<EmailShell previewText={`Your payment for ${tierName} didn't go through`}>
<EmailHeading>Your payment didn&apos;t go through</EmailHeading>
<EmailParagraph>
Hi {studentName}, we couldn&apos;t complete your payment for{' '}
{tierName}. No charge was made. You can try again below. If a
payment method was declined, use a different one.
</EmailParagraph>
<EmailButton href={retryUrl}>Try again →</EmailButton>
</EmailShell>
);
}
51 changes: 51 additions & 0 deletions src/emails/PaymentReceiptEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
EmailButton,
EmailDetailBox,
EmailDetailRow,
EmailHeading,
EmailParagraph,
EmailShell,
} from './shared';

export interface PaymentReceiptEmailProps {
studentName: string;
tierName: string;
amount: string;
method: string;
paidAt: string;
paymentsUrl: string;
receiptUrl?: string | null;
}

/**
* Receipt for a completed payment. Not wired to a live trigger in this build
* (the launch stripped PayMongo, so no code currently creates `Payment`
* rows). Ready to call from wherever payment confirmation lands next.
*/
export default function PaymentReceiptEmail({
studentName,
tierName,
amount,
method,
paidAt,
paymentsUrl,
receiptUrl,
}: PaymentReceiptEmailProps) {
return (
<EmailShell previewText={`Receipt for your ${tierName} payment (${amount})`}>
<EmailHeading>Payment received</EmailHeading>
<EmailParagraph>
Hi {studentName}, thanks for your payment. Here&apos;s your receipt.
</EmailParagraph>
<EmailDetailBox>
<EmailDetailRow label="Plan" value={tierName} />
<EmailDetailRow label="Amount" value={amount} />
<EmailDetailRow label="Method" value={method} />
<EmailDetailRow label="Date" value={paidAt} />
</EmailDetailBox>
<EmailButton href={receiptUrl || paymentsUrl}>
{receiptUrl ? 'Download receipt →' : 'View payment history →'}
</EmailButton>
</EmailShell>
);
}
Loading