diff --git a/.env.example b/.env.example index f60cd123e..727704908 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,60 @@ +# Giscus configuration +NEXT_PUBLIC_GISCUS_REPO= +NEXT_PUBLIC_GISCUS_REPOSITORY_ID= +NEXT_PUBLIC_GISCUS_CATEGORY= +NEXT_PUBLIC_GISCUS_CATEGORY_ID= +NEXT_PUBLIC_UTTERANCES_REPO= +NEXT_PUBLIC_DISQUS_SHORTNAME= + +# Mailchimp configuration +MAILCHIMP_API_KEY= +MAILCHIMP_API_SERVER= +MAILCHIMP_AUDIENCE_ID= + +# Buttondown configuration +BUTTONDOWN_API_KEY= + +# ConvertKit configuration +CONVERTKIT_API_KEY= +# curl https://api.convertkit.com/v3/forms?api_key= to get your form ID +CONVERTKIT_FORM_ID= + +# Klaviyo configuration +KLAVIYO_API_KEY= +KLAVIYO_LIST_ID= + +# Revue configuration +REVUE_API_KEY= + +# EmailOctopus configuration +EMAILOCTOPUS_API_KEY= +EMAILOCTOPUS_LIST_ID= + +# GitHub configuration GH_ACCESS_TOKEN= GH_ORG=OpenSats GH_APP_REPO=applications GH_REPORTS_REPO=reports + +# SendGrid configuration SENDGRID_API_KEY= SENDGRID_RECIPIENT= SENDGRID_RECEPIENT= SENDGRID_CC= SENDGRID_VERIFIED_SENDER= + +# BTC Pay configuration BTCPAY_STORE_ID= BTCPAY_URL= BTCPAY_API_KEY= + +# Stripe configuration STRIPE_SECRET_KEY= ZAPRITE_USER_UUID= + +# Google configuration NEXT_PUBLIC_GOOGLE_DOC_ID= -NEXT_PUBLIC_GOOGLE_API_KEY= \ No newline at end of file +NEXT_PUBLIC_GOOGLE_API_KEY= + +# Session configuration +SESSION_SECRET=complex_password_at_least_32_characters_long diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 000000000..2da2ff72b --- /dev/null +++ b/.env.local.example @@ -0,0 +1,12 @@ +# GitHub Configuration +GH_ACCESS_TOKEN=your_github_access_token +GH_BOT_TOKEN=your_github_bot_token +GH_ORG=OpenSats +GH_REPORTS_REPO=reports + +# Email Configuration +SENDGRID_API_KEY=your_sendgrid_api_key +EMAIL_FROM=support@opensats.org + +# Session Configuration +SECRET_COOKIE_PASSWORD=complex_password_at_least_32_characters_long diff --git a/.eslintignore b/.eslintignore index 3c3629e64..229170597 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ node_modules +scripts/ diff --git a/.gitignore b/.gitignore index 5fa604dc0..17932eab5 100644 --- a/.gitignore +++ b/.gitignore @@ -33,9 +33,11 @@ npm-debug.log* # local env files .env.local +.env.local.rtf .env.development.local .env.test.local .env.production.local +cookies.txt # Contentlayer .contentlayer diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 000000000..e21d8a486 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,69 @@ +# Grantee Report Submission System + +## Overview + +This pull request implements a complete report submission system for OpenSats grantees. The system allows grantees to validate their grant numbers, submit progress reports, and receive confirmation emails with a copy of their submitted reports. + +## Key Features + +### 1. Grantee Report Submission Flow + +- **Two-Step Process**: Grant validation followed by report submission +- **Grant Validation**: Verifies grant IDs (6-7 digit numbers) against GitHub issues +- **Report Form**: Collects project updates, plans for next quarter, and use of funds +- **Report Preview**: Allows grantees to preview their report before submission + +### 2. Email Notifications + +- **Confirmation Emails**: Sends a confirmation email to grantees when they submit a report +- **Report Copy**: Includes a full copy of the submitted report in the email +- **SendGrid Integration**: Uses SendGrid API for reliable email delivery +- **OpenSats Branding**: Emails match OpenSats branding with orange header + +### 3. GitHub Integration + +- **Issue Comments**: Submits reports as comments on the corresponding grant issue +- **Markdown Formatting**: Formats reports in Markdown for readability +- **Grant Verification**: Uses GitHub API to verify grant numbers + +### 4. Development and Testing Support + +- **Test Mode**: Automatically enables test mode in non-production environments +- **Test Grant IDs**: Supports test grant IDs (123456, 234567) in development +- **Email Testing**: Includes a test script for verifying email functionality + +## Files Changed + +- **API Routes**: Implemented report submission and grant validation endpoints +- **UI Components**: Created report form and preview components +- **Email Utilities**: Built email sending functionality with SendGrid integration +- **Documentation**: Added comprehensive setup and testing guides + +## Testing + +The system has been thoroughly tested in development mode: + +1. **Grant Validation**: Verified that valid grant IDs are properly recognized +2. **Report Submission**: Confirmed that reports are correctly formatted and submitted +3. **Email Delivery**: Tested that confirmation emails are sent with report content +4. **Error Handling**: Verified that the system handles errors gracefully + +## Environment Variables + +The following environment variables are required: + +| Variable | Description | +|----------|-------------| +| `SENDGRID_API_KEY` | SendGrid API key for email sending | +| `EMAIL_FROM` | Sender email address (support@opensats.org) | +| `GH_ACCESS_TOKEN` | GitHub access token for API interactions | +| `GH_ORG` | GitHub organization (OpenSats) | +| `GH_REPORTS_REPO` | GitHub repository for reports (reports) | + +## Next Steps + +After merging this PR, the following steps are recommended: + +1. **User Testing**: Conduct testing with actual grantees +2. **Documentation**: Create user-facing documentation for grantees +3. **Monitoring**: Set up monitoring for the report submission process diff --git a/components/ClosedNotice.tsx b/components/ClosedNotice.tsx new file mode 100644 index 000000000..d4dae82f1 --- /dev/null +++ b/components/ClosedNotice.tsx @@ -0,0 +1,45 @@ +import Link from './Link' + +const ClosedNotice = () => { + return ( +
+
+
+ + + +
+
+

Applications are currently closed!

+

+ Grant applications are currently closed as per our{' '} + + quarterly schedule + + . Please have a look at{' '} + last year's report to + see what kind of projects we support. +

+

+ If you want to prepare a submission, please get familiar with our{' '} + application criteria as well as + our grant selection process. +

+

+ We will re-open applications as soon as we can. Two weeks™. + Okay, maybe four. +

+
+
+
+ ) +} + +export default ClosedNotice diff --git a/components/GrantReportForm.tsx b/components/GrantReportForm.tsx new file mode 100644 index 000000000..bff728ba3 --- /dev/null +++ b/components/GrantReportForm.tsx @@ -0,0 +1,637 @@ +import { useState, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { fetchPostJSON } from '../utils/api-helpers' +import FormButton from '@/components/FormButton' +import CustomLink from '@/components/Link' +import { useRouter } from 'next/router' +import ReportPreview from '@/components/ReportPreview' +import CryptoJS from 'crypto-js' + +interface GrantReportFormProps { + grantDetails: { + project_name: string + issue_number: number + } + email_hash: string +} + +interface GrantReportFormData { + project_name: string + report_number: string + time_spent: string + next_quarter: string + money_usage: string + help_needed?: string +} + +// Encryption key for local storage (in a real app, this would be more securely managed) +const STORAGE_KEY = 'opensats_report_draft' +const ENCRYPTION_KEY = 'opensats_secure_storage' +// Set expiration time for saved data (30 days in milliseconds) +const STORAGE_EXPIRATION = 30 * 24 * 60 * 60 * 1000 + +export default function GrantReportForm({ + grantDetails, + email_hash, +}: GrantReportFormProps) { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState() + const [showPreview, setShowPreview] = useState(false) + const [recoveredData, setRecoveredData] = useState(false) + + const { + register, + handleSubmit, + formState: { errors, isDirty }, + watch, + setValue, + getValues, + reset, + } = useForm({ + defaultValues: { + project_name: grantDetails.project_name, + report_number: '', + time_spent: '', + next_quarter: '', + money_usage: '', + help_needed: '', + }, + }) + + // Watch all form fields for preview + const watchAllFields = watch() + + // Clear all saved report data for this grant + const clearSavedData = () => { + try { + // Find all keys in localStorage that match the pattern for this grant + const keysToRemove = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if ( + key && + key.startsWith(`${STORAGE_KEY}_${grantDetails.issue_number}`) + ) { + keysToRemove.push(key) + } + } + + // Remove all matching keys + keysToRemove.forEach((key) => localStorage.removeItem(key)) + + return keysToRemove.length > 0 + } catch (e) { + console.error('Error clearing saved form data:', e) + return false + } + } + + // Load saved form data from localStorage on initial render + useEffect(() => { + try { + const storageKey = `${STORAGE_KEY}_${grantDetails.issue_number}_${ + watchAllFields.report_number || 'draft' + }` + const savedData = localStorage.getItem(storageKey) + + if (savedData) { + // Decrypt the data + const decryptedBytes = CryptoJS.AES.decrypt(savedData, ENCRYPTION_KEY) + const decryptedText = decryptedBytes.toString(CryptoJS.enc.Utf8) + + if (decryptedText) { + const decryptedData = JSON.parse(decryptedText) + + // Check if the data has expired + if ( + decryptedData.timestamp && + Date.now() - decryptedData.timestamp > STORAGE_EXPIRATION + ) { + // Data has expired, remove it + localStorage.removeItem(storageKey) + return + } + + // Set form values from saved data + if (decryptedData.formData) { + Object.keys(decryptedData.formData).forEach((key) => { + setValue( + key as keyof GrantReportFormData, + decryptedData.formData[key] + ) + }) + setRecoveredData(true) + } + } + } + } catch (e) { + console.error('Error loading saved form data:', e) + // If there's an error, we just continue with empty form + } + }, [grantDetails.issue_number, setValue]) + + // Save form data to localStorage whenever it changes + useEffect(() => { + if (isDirty && watchAllFields.report_number) { + try { + const formData = getValues() + const storageKey = `${STORAGE_KEY}_${grantDetails.issue_number}_${watchAllFields.report_number}` + + // Store data with timestamp for expiration checking + const dataToStore = { + formData, + timestamp: Date.now(), + } + + // Encrypt the data before storing + const encryptedData = CryptoJS.AES.encrypt( + JSON.stringify(dataToStore), + ENCRYPTION_KEY + ).toString() + + localStorage.setItem(storageKey, encryptedData) + + // Remove draft data if we have a report number + if (watchAllFields.report_number) { + localStorage.removeItem( + `${STORAGE_KEY}_${grantDetails.issue_number}_draft` + ) + } + } catch (e) { + console.error('Error saving form data:', e) + } + } + }, [ + watchAllFields, + grantDetails.issue_number, + isDirty, + getValues, + watchAllFields.report_number, + ]) + + const onSubmit = async (data: GrantReportFormData) => { + setLoading(true) + setError(undefined) + + try { + const response = await fetchPostJSON('/api/submit-report', { + ...data, + issue_number: grantDetails.issue_number, + email_hash, + }) + + if (response.error) { + setError(response.error) + setLoading(false) + return + } + + // Clear all saved form data for this grant after successful submission + clearSavedData() + + setSubmitted(true) + setLoading(false) + router.push('/reports/success') + } catch (e) { + setError( + 'Error submitting report. Your data has been saved locally. Please try again later.' + ) + setLoading(false) + } + } + + if (submitted) { + return ( +
+

+ Your report has been submitted successfully. +

+

+ + Submit another report + +

+
+ ) + } + + return ( +
+ {/* Instructions */} +
+
+ + + +

+ Refer to our{' '} + + Grantee FAQ + {' '} + or{' '} + + guidelines below + {' '} + for help building a quality progress report. Format your report in + markdown. +

+
+
+ + {recoveredData && ( +
+
+ + + +
+

+ Recovered Data +

+
+

+ We've recovered your previously entered data. You can continue + editing or submit it. +

+
+
+ +
+
+
+
+ )} + + {/* Project Name */} +
+ + + {errors.project_name && ( + + {errors.project_name.message} + + )} +
+ + {/* Progress Report # */} +
+ + + {errors.report_number && ( + + {errors.report_number.message} + + )} +
+ + {/* Project Updates */} +
+ +