diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5cb0694 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +### Variables generated for cloud via npx convex dev +CONVEX_DEPLOYMENT=dev:adjective-animal-123 +NEXT_PUBLIC_CONVEX_URL=https://adjective-animal-123.convex.cloud + + +### Variables to be manually set for both cloud and self hosted +NEXT_PUBLIC_SITE_URL=https://localhost:3000 +SITE_URL=https://localhost:3000 + + +### Variables to be manually set for cloud only +NEXT_PUBLIC_CONVEX_SITE_URL=https://adjective-animal-123.convex.site + + +### Variables to be manually set for self hosted only +# CONVEX_SELF_HOSTED_URL='http://127.0.0.1:3210' +# CONVEX_SELF_HOSTED_ADMIN_KEY='' +# NEXT_PUBLIC_CONVEX_URL='http://127.0.0.1:3210' +# NEXT_PUBLIC_CONVEX_SITE_URL='http://127.0.0.1:3211' \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..629c694 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,105 @@ +name: Bug Report +description: Report a bug or unexpected behavior +title: "[Bug]: " +labels: ["bug", "needs-triage"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Tell us what you see! + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: Describe the expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what actually happened + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem + placeholder: Paste or drag images here + + - type: dropdown + id: severity + attributes: + label: Severity + description: How severe is this bug? + options: + - Low - Minor issue, workaround available + - Medium - Affects functionality but not critical + - High - Major functionality broken + - Critical - Application unusable + validations: + required: true + + - type: input + id: browser + attributes: + label: Browser + description: What browser are you using? + placeholder: e.g., Chrome 120, Firefox 121, Safari 17 + + - type: input + id: os + attributes: + label: Operating System + description: What OS are you using? + placeholder: e.g., Windows 11, macOS 14, Ubuntu 22.04 + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the problem here + placeholder: Any additional information that might help + + - type: checkboxes + id: terms + attributes: + label: Checklist + description: Please confirm the following + options: + - label: I have searched for similar issues before creating this one + required: true + - label: I have provided all the information requested above + required: true + - label: This issue is reproducible + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5300e0b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: GitHub Discussions + url: https://github.com/hasnaintypes/sendable-ai/discussions + about: Ask questions and discuss ideas with the community + - name: Documentation + url: https://github.com/hasnaintypes/sendable-ai/blob/main/README.md + about: Read the documentation for setup and usage guides + - name: Contributing Guide + url: https://github.com/hasnaintypes/sendable-ai/blob/main/CONTRIBUTING.md + about: Learn how to contribute to the project diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..238c727 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,115 @@ +name: Feature Request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe. + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like + placeholder: A clear and concise description of what you want to happen + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Describe alternatives you've considered + placeholder: A clear and concise description of any alternative solutions or features + + - type: dropdown + id: category + attributes: + label: Feature Category + description: What area does this feature belong to? + options: + - Lead Management + - Email Campaigns + - Inbox Integration + - Analytics & Reporting + - Authentication & Security + - UI/UX + - API & Integrations + - Performance + - Other + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature? + options: + - Low - Nice to have + - Medium - Would improve workflow + - High - Significantly impacts usability + - Critical - Blocking current usage + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Describe a specific use case for this feature + placeholder: | + As a [type of user] + I want [goal] + So that [benefit] + validations: + required: true + + - type: textarea + id: mockups + attributes: + label: Mockups/Examples + description: If applicable, add mockups, screenshots, or examples + placeholder: Paste or drag images here + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the feature request here + placeholder: Any additional information that might help + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Would you be interested in contributing this feature? + options: + - label: I would like to work on this feature + required: false + + - type: checkboxes + id: terms + attributes: + label: Checklist + description: Please confirm the following + options: + - label: I have searched for similar feature requests before creating this one + required: true + - label: I have provided all the information requested above + required: true + - label: This feature aligns with the project's goals + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8097783 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,100 @@ +## Description + + + + +## Type of Change + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] UI/UX improvement +- [ ] Code refactoring (no functional changes) +- [ ] Performance improvement +- [ ] Test addition or update +- [ ] Configuration change + +## Related Issues + + +- Closes # +- Related to # + +## Changes Made + + +- +- +- + +## Testing Done + + +- [ ] Tested locally in development environment +- [ ] Tested in production-like environment +- [ ] Added unit tests +- [ ] Added integration tests +- [ ] Tested on multiple browsers (Chrome, Firefox, Safari, Edge) +- [ ] Tested mobile responsiveness +- [ ] Verified backward compatibility + +### Test Steps + + +1. +2. +3. + +## Screenshots / Videos + + +### Before + + + +### After + + + +## Documentation + + +- [ ] Documentation has been updated (README, CONTRIBUTING, etc.) +- [ ] Code comments have been added/updated +- [ ] No documentation changes needed + +## Breaking Changes + + + + +## Code Review Checklist + + +- [ ] Code follows the project's coding standards +- [ ] Changes are well-documented +- [ ] No unnecessary dependencies added +- [ ] Git history is clean (meaningful commits, no merge commits) +- [ ] Tests pass locally and in CI +- [ ] No console errors or warnings +- [ ] Performance impact considered +- [ ] Security implications considered + +## Additional Notes + + + + +--- + + diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..066c52a --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,60 @@ +# Auto-label PRs based on changed files + +# Frontend +'frontend': + - changed-files: + - any-glob-to-any-file: 'src/app/**/*' + - any-glob-to-any-file: 'src/components/**/*' + +# Backend +'backend': + - changed-files: + - any-glob-to-any-file: 'convex/**/*' + +# Authentication +'auth': + - changed-files: + - any-glob-to-any-file: 'convex/auth/**/*' + - any-glob-to-any-file: 'convex/betterAuth/**/*' + - any-glob-to-any-file: 'src/components/auth/**/*' + - any-glob-to-any-file: 'src/lib/auth/**/*' + +# Email +'email': + - changed-files: + - any-glob-to-any-file: 'convex/emails/**/*' + +# UI Components +'ui': + - changed-files: + - any-glob-to-any-file: 'src/components/ui/**/*' + +# Styling +'styling': + - changed-files: + - any-glob-to-any-file: '**/*.css' + - any-glob-to-any-file: 'tailwind.config.*' + +# Configuration +'config': + - changed-files: + - any-glob-to-any-file: '*.config.*' + - any-glob-to-any-file: 'tsconfig.json' + - any-glob-to-any-file: '.eslintrc*' + +# Documentation +'documentation': + - changed-files: + - any-glob-to-any-file: '*.md' + - any-glob-to-any-file: 'docs/**/*' + +# CI/CD +'ci-cd': + - changed-files: + - any-glob-to-any-file: '.github/**/*' + +# Dependencies +'dependencies': + - changed-files: + - any-glob-to-any-file: 'package.json' + - any-glob-to-any-file: 'package-lock.json' diff --git a/.github/workflows/auto-pr-description.yml b/.github/workflows/auto-pr-description.yml new file mode 100644 index 0000000..0be949e --- /dev/null +++ b/.github/workflows/auto-pr-description.yml @@ -0,0 +1,218 @@ +name: Auto PR Description + +on: + pull_request: + types: [opened, reopened] + +jobs: + auto-describe: + name: Auto Generate PR Description + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate PR Description + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Skip if PR already has a detailed description (>100 chars) + if (pr.body && pr.body.length > 100) { + console.log('PR already has a description, skipping...'); + return; + } + + // Get commits + const { data: commits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + // Get changed files + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + // Analyze commits + const commitMessages = commits.map(c => c.commit.message); + const uniqueMessages = [...new Set(commitMessages.filter(m => + !m.toLowerCase().includes('merge') && + !m.toLowerCase().startsWith('wip') + ))]; + + // Categorize changes + const categories = { + features: [], + fixes: [], + docs: [], + refactor: [], + tests: [], + chore: [], + other: [] + }; + + uniqueMessages.forEach(msg => { + const lower = msg.toLowerCase(); + if (lower.startsWith('feat') || lower.includes('add') || lower.includes('implement')) { + categories.features.push(msg); + } else if (lower.startsWith('fix') || lower.includes('bug') || lower.includes('resolve')) { + categories.fixes.push(msg); + } else if (lower.startsWith('docs') || lower.includes('documentation')) { + categories.docs.push(msg); + } else if (lower.startsWith('refactor') || lower.includes('cleanup') || lower.includes('improve')) { + categories.refactor.push(msg); + } else if (lower.startsWith('test') || lower.includes('testing')) { + categories.tests.push(msg); + } else if (lower.startsWith('chore') || lower.includes('update dep')) { + categories.chore.push(msg); + } else { + categories.other.push(msg); + } + }); + + // Analyze file changes + const fileCategories = { + backend: files.filter(f => f.filename.includes('convex/')), + frontend: files.filter(f => f.filename.includes('src/app/') || f.filename.includes('src/components/')), + auth: files.filter(f => f.filename.includes('auth')), + config: files.filter(f => f.filename.match(/\.(json|yml|yaml|config|env)$/)), + tests: files.filter(f => f.filename.includes('test') || f.filename.includes('spec')), + docs: files.filter(f => f.filename.match(/\.(md|txt)$/)) + }; + + // Determine PR type + let prType = 'chore'; + let typeEmoji = '🔧'; + + if (categories.features.length > 0) { + prType = 'feat'; + typeEmoji = '✨'; + } else if (categories.fixes.length > 0) { + prType = 'fix'; + typeEmoji = '🐛'; + } else if (categories.docs.length > 0) { + prType = 'docs'; + typeEmoji = '📝'; + } else if (categories.refactor.length > 0) { + prType = 'refactor'; + typeEmoji = '♻️'; + } else if (categories.tests.length > 0) { + prType = 'test'; + typeEmoji = '✅'; + } + + // Generate title if generic + let newTitle = pr.title; + const genericTitles = ['update', 'changes', 'patch', 'pr', 'merge']; + const isGeneric = genericTitles.some(t => pr.title.toLowerCase().includes(t)); + + if (isGeneric || pr.title.length < 10) { + // Try to create better title from commits + const mainCommit = uniqueMessages[0] || 'Code changes'; + const cleanTitle = mainCommit + .replace(/^(feat|fix|docs|refactor|test|chore)(\(.*?\))?:\s*/i, '') + .trim(); + newTitle = `${prType}: ${cleanTitle}`; + } + + // Generate description + let description = '## 📝 Description\n\n'; + + // Summary + const totalChanges = files.reduce((sum, f) => sum + f.changes, 0); + const areas = Object.entries(fileCategories) + .filter(([_, f]) => f.length > 0) + .map(([name, _]) => name); + + description += `This PR introduces changes to ${areas.join(', ')} with ${files.length} files modified (${totalChanges} lines changed).\n\n`; + + // Main changes + description += '## 🔄 Changes Made\n\n'; + + for (const [category, messages] of Object.entries(categories)) { + if (messages.length > 0) { + const categoryName = category.charAt(0).toUpperCase() + category.slice(1); + description += `### ${categoryName}\n`; + messages.forEach(msg => { + const cleaned = msg.replace(/^(feat|fix|docs|refactor|test|chore)(\(.*?\))?:\s*/i, '').trim(); + description += `- ${cleaned}\n`; + }); + description += '\n'; + } + } + + // File changes breakdown + description += '## 📁 Files Changed\n\n'; + for (const [category, categoryFiles] of Object.entries(fileCategories)) { + if (categoryFiles.length > 0) { + const categoryName = category.charAt(0).toUpperCase() + category.slice(1); + description += `**${categoryName}** (${categoryFiles.length} files)\n`; + } + } + + // Type of change + description += '\n## 🏷️ Type of Change\n\n'; + description += `- [${prType === 'fix' ? 'x' : ' '}] Bug fix\n`; + description += `- [${prType === 'feat' ? 'x' : ' '}] New feature\n`; + description += `- [${prType === 'docs' ? 'x' : ' '}] Documentation update\n`; + description += `- [${prType === 'refactor' ? 'x' : ' '}] Code refactoring\n`; + description += `- [${prType === 'test' ? 'x' : ' '}] Test addition or update\n`; + description += `- [ ] Breaking change\n`; + + // Testing + description += '\n## ✅ Testing Done\n\n'; + description += '- [ ] Tested locally in development environment\n'; + description += '- [ ] Added unit tests\n'; + description += '- [ ] Verified in production-like environment\n'; + + // Additional sections + description += '\n## 📷 Screenshots / Videos\n\n'; + description += '_Add screenshots or videos if applicable_\n\n'; + + description += '## 📚 Additional Notes\n\n'; + description += '_Add any additional context or notes for reviewers_\n\n'; + + description += '---\n'; + description += `\n_🤖 This description was auto-generated from ${commits.length} commit(s). Please review and update as needed._`; + + // Update PR + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + title: newTitle, + body: description + }); + + console.log(`✅ Updated PR #${pr.number}`); + console.log(`Title: ${newTitle}`); + console.log(`Type: ${prType} ${typeEmoji}`); + + - name: Add Comment + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: '🤖 **Auto-generated PR description has been added!**\n\n' + + 'The title and description were generated based on your commits and file changes.\n' + + 'Please review and update:\n' + + '- ✏️ Edit the description to add more context if needed\n' + + '- 🔗 Link related issues using `Closes #123`\n' + + '- ✅ Check the boxes for type of change and testing done\n' + + '- 📷 Add screenshots/videos if UI changes were made\n\n' + + 'See our [Contributing Guidelines](../CONTRIBUTING.md) for best practices.' + }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b9aecf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +jobs: + lint-and-typecheck: + name: Lint & Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + continue-on-error: true + + - name: Type check frontend + run: npx tsc --noEmit + + - name: Type check Convex backend + run: npx tsc -p convex --noEmit + + build: + name: Build Application + runs-on: ubuntu-latest + needs: lint-and-typecheck + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Set up Convex + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + CONVEX_DEPLOYMENT: ${{ secrets.CONVEX_DEPLOYMENT }} + run: | + npx convex dev --once --configure=new + + - name: Build Next.js + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + CONVEX_DEPLOYMENT: ${{ secrets.CONVEX_DEPLOYMENT }} + run: npm run build + + check-formatting: + name: Check Formatting + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check Prettier formatting + run: npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" "convex/**/*.{ts,js}" + continue-on-error: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f72adeb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: CodeQL Security Scan + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + # Run weekly on Monday at 00:00 UTC + - cron: '0 0 * * 1' + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze Code + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: ['javascript', 'typescript'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..4cd0fbd --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,22 @@ +name: Dependency Review + +on: [pull_request] + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + name: Review Dependencies + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: moderate + comment-summary-in-pr: true diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..c2859de --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,84 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + label: + name: Auto Label PR + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Label based on files + uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + + - name: Label based on PR title + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.pull_request.title.toLowerCase(); + const labels = []; + + // Check PR title prefix + if (title.startsWith('feat')) labels.push('feature'); + if (title.startsWith('fix')) labels.push('bug'); + if (title.startsWith('docs')) labels.push('documentation'); + if (title.startsWith('refactor')) labels.push('refactor'); + if (title.startsWith('test')) labels.push('testing'); + if (title.startsWith('chore')) labels.push('chore'); + if (title.startsWith('perf')) labels.push('performance'); + + // Add size labels based on changes + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + const changes = files.reduce((sum, file) => sum + file.changes, 0); + + if (changes < 50) labels.push('size/small'); + else if (changes < 200) labels.push('size/medium'); + else labels.push('size/large'); + + // Add labels to PR + if (labels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: labels + }); + } + + - name: Check PR description + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.pull_request.body || ''; + + if (body.length < 50) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: ['needs-description'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: 'This PR needs a more detailed description. Please add:\n\n- What changes were made\n- Why these changes were necessary\n- How to test the changes\n\nRefer to our [Contributing Guidelines](../CONTRIBUTING.md) for more details.' + }); + } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..73bd22f --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,63 @@ +name: Close Stale Issues and PRs + +on: + schedule: + # Run daily at 00:00 UTC + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + name: Clean up stale issues and PRs + runs-on: ubuntu-latest + + steps: + - name: Stale Issues and PRs + uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Issues + stale-issue-message: | + This issue has been automatically marked as stale because it has not had any activity for 30 days. + + It will be closed in 7 days if no further activity occurs. + + If you believe this issue is still relevant, please leave a comment and we'll keep it open. + + Thank you for your contributions! + close-issue-message: | + This issue has been automatically closed due to inactivity. + + If you believe this is still relevant, feel free to reopen it or create a new issue with updated information. + days-before-stale: 30 + days-before-close: 7 + stale-issue-label: 'stale' + exempt-issue-labels: 'pinned,security,roadmap,in-progress' + + # Pull Requests + stale-pr-message: | + This pull request has been automatically marked as stale because it has not had any activity for 14 days. + + It will be closed in 7 days if no further activity occurs. + + If you're still working on this, please leave a comment and we'll keep it open. + + Thank you for your contributions! + close-pr-message: | + This pull request has been automatically closed due to inactivity. + + If you'd like to continue working on this, feel free to reopen it or create a new PR. + days-before-pr-stale: 14 + days-before-pr-close: 7 + stale-pr-label: 'stale' + exempt-pr-labels: 'pinned,security,in-progress,needs-review' + + # General settings + operations-per-run: 100 + remove-stale-when-updated: true + ascending: true diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml new file mode 100644 index 0000000..31ce0d7 --- /dev/null +++ b/.github/workflows/welcome.yml @@ -0,0 +1,61 @@ +name: Welcome + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + welcome: + name: Welcome New Contributors + runs-on: ubuntu-latest + + steps: + - name: Welcome First-Time Issue Creator + uses: actions/first-interaction@v1 + if: github.event_name == 'issues' + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: | + **Welcome to Sendable.ai!** + + Thank you for opening your first issue! + + We appreciate you taking the time to contribute to the project. A maintainer will review your issue soon and provide feedback. + + In the meantime, you might want to: + - Check our [Contributing Guide](../CONTRIBUTING.md) for more information + - Join our [Discussions](https://github.com/${{ github.repository }}/discussions) to chat with the community + - Review the [README](../README.md) for project overview and setup instructions + + Happy coding! + + - name: Welcome First-Time PR Creator + uses: actions/first-interaction@v1 + if: github.event_name == 'pull_request_target' + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-message: | + **Welcome to Sendable.ai!** + + Thank you for opening your first pull request! + + We're excited to review your contribution. Here's what happens next: + + 1. **Automated checks** will run to verify your code + 2. A **maintainer will review** your changes + 3. You may receive **feedback or change requests** + 4. Once approved, your PR will be **merged**! + + **Quick tips:** + - Make sure all CI checks pass + - Respond to any feedback promptly + - Keep your PR focused on a single concern + - Check out our [Contributing Guide](../CONTRIBUTING.md) for best practices + + Thank you for contributing! diff --git a/.gitignore b/.gitignore index 5ef6a52..b643581 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/.vscode/rules/convex_rules.mdc b/.vscode/rules/convex_rules.mdc new file mode 100644 index 0000000..3c55e27 --- /dev/null +++ b/.vscode/rules/convex_rules.mdc @@ -0,0 +1,705 @@ +--- +description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples +globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx +--- + +# Convex guidelines +## Function guidelines +### New function syntax +- ALWAYS use the new function syntax for Convex functions. For example: +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; +export const f = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + // Function body + }, +}); +``` + +### Http endpoint syntax +- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: +```typescript +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; +const http = httpRouter(); +http.route({ + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { + const body = await req.bytes(); + return new Response(body, { status: 200 }); + }), +}); +``` +- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. + +### Validators +- Below is an example of an array validator: +```typescript +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export default mutation({ +args: { + simpleArray: v.array(v.union(v.string(), v.number())), +}, +handler: async (ctx, args) => { + //... +}, +}); +``` +- Below is an example of a schema with validators that codify a discriminated union type: +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ) +}); +``` +- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value: +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const exampleQuery = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This query returns a null value"); + return null; + }, +}); +``` +- Here are the valid Convex types along with their respective validators: +Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | +| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Id | string | `doc._id` | `v.id(tableName)` | | +| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | +| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | +| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | +| Boolean | boolean | `true` | `v.boolean()` | +| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | +| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | +| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | +| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | + +### Function registration +- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. +- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. +- You CANNOT register a function through the `api` or `internal` objects. +- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator. +- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`. + +### Function calling +- Use `ctx.runQuery` to call a query from a query, mutation, or action. +- Use `ctx.runMutation` to call a mutation from a mutation or action. +- Use `ctx.runAction` to call an action from an action. +- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. +- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. +- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. +- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, +``` +export const f = query({ + args: { name: v.string() }, + returns: v.string(), + handler: async (ctx, args) => { + return "Hello " + args.name; + }, +}); + +export const g = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); + return null; + }, +}); +``` + +### Function references +- Function references are pointers to registered Convex functions. +- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. +- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. +- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. +- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. +- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. + +### Api design +- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory. +- Use `query`, `mutation`, and `action` to define public functions. +- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions. + +### Pagination +- Paginated queries are queries that return a list of results in incremental pages. +- You can define pagination using the following syntax: + +```ts +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; +export const listWithExtraArg = query({ + args: { paginationOpts: paginationOptsValidator, author: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .filter((q) => q.eq(q.field("author"), args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); +``` +Note: `paginationOpts` is an object with the following properties: +- `numItems`: the maximum number of documents to return (the validator is `v.number()`) +- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) +- A query that ends in `.paginate()` returns an object that has the following properties: + - page (contains an array of documents that you fetches) + - isDone (a boolean that represents whether or not this is the last page of documents) + - continueCursor (a string that represents the cursor to use to fetch the next page of documents) + + +## Validator guidelines +- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead. +- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported. + +## Schema guidelines +- Always define your schema in `convex/schema.ts`. +- Always import the schema definition functions from `convex/server`. +- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. +- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". +- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. + +## Typescript guidelines +- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. +- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: +```ts +import { query } from "./_generated/server"; +import { Doc, Id } from "./_generated/dataModel"; + +export const exampleQuery = query({ + args: { userIds: v.array(v.id("users")) }, + returns: v.record(v.id("users"), v.string()), + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get("users", userId); + if (user) { + idToUsername[user._id] = user.username; + } + } + + return idToUsername; + }, +}); +``` +- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. +- Always use `as const` for string literals in discriminated union types. +- When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];` +- When using the `Record` type, make sure to always define your records as `const record: Record = {...};` +- Always add `@types/node` to your `package.json` when using any Node.js built-in modules. + +## Full text search guidelines +- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: + +const messages = await ctx.db + .query("messages") + .withSearchIndex("search_body", (q) => + q.search("body", "hello hi").eq("channel", "#general"), + ) + .take(10); + +## Query guidelines +- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. +- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result. +- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. +- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. +### Ordering +- By default Convex always returns documents in ascending `_creationTime` order. +- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. +- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. + + +## Mutation guidelines +- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` +- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` + +## Action guidelines +- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. +- Never use `ctx.db` inside of an action. Actions don't have access to the database. +- Below is an example of the syntax for an action: +```ts +import { action } from "./_generated/server"; + +export const exampleAction = action({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, +}); +``` + +## Scheduling guidelines +### Cron guidelines +- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. +- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. +- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, +```ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; + +const empty = internalAction({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("empty"); + }, +}); + +const crons = cronJobs(); + +// Run `internal.crons.empty` every two hours. +crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); + +export default crons; +``` +- You can register Convex functions within `crons.ts` just like any other file. +- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. + + +## File storage guidelines +- Convex includes file storage for large files like images, videos, and PDFs. +- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. +- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. + + Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. +``` +import { query } from "./_generated/server"; +import { Id } from "./_generated/dataModel"; + +type FileMetadata = { + _id: Id<"_storage">; + _creationTime: number; + contentType?: string; + sha256: string; + size: number; +} + +export const exampleQuery = query({ + args: { fileId: v.id("_storage") }, + returns: v.null(), + handler: async (ctx, args) => { + const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); + console.log(metadata); + return null; + }, +}); +``` +- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. + + +# Examples: +## Example: chat-app + +### Task +``` +Create a real-time chat application backend with AI responses. The app should: +- Allow creating users with names +- Support multiple chat channels +- Enable users to send messages to channels +- Automatically generate AI responses to user messages +- Show recent message history + +The backend should provide APIs for: +1. User management (creation) +2. Channel management (creation) +3. Message operations (sending, listing) +4. AI response generation using OpenAI's GPT-4 + +Messages should be stored with their channel, author, and content. The system should maintain message order +and limit history display to the 10 most recent messages per channel. + +``` + +### Analysis +1. Task Requirements Summary: +- Build a real-time chat backend with AI integration +- Support user creation +- Enable channel-based conversations +- Store and retrieve messages with proper ordering +- Generate AI responses automatically + +2. Main Components Needed: +- Database tables: users, channels, messages +- Public APIs for user/channel management +- Message handling functions +- Internal AI response generation system +- Context loading for AI responses + +3. Public API and Internal Functions Design: +Public Mutations: +- createUser: + - file path: convex/index.ts + - arguments: {name: v.string()} + - returns: v.object({userId: v.id("users")}) + - purpose: Create a new user with a given name +- createChannel: + - file path: convex/index.ts + - arguments: {name: v.string()} + - returns: v.object({channelId: v.id("channels")}) + - purpose: Create a new channel with a given name +- sendMessage: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()} + - returns: v.null() + - purpose: Send a message to a channel and schedule a response from the AI + +Public Queries: +- listMessages: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels")} + - returns: v.array(v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + })) + - purpose: List the 10 most recent messages from a channel in descending creation order + +Internal Functions: +- generateResponse: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels")} + - returns: v.null() + - purpose: Generate a response from the AI for a given channel +- loadContext: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels")} + - returns: v.array(v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + })) +- writeAgentResponse: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels"), content: v.string()} + - returns: v.null() + - purpose: Write an AI response to a given channel + +4. Schema Design: +- users + - validator: { name: v.string() } + - indexes: +- channels + - validator: { name: v.string() } + - indexes: +- messages + - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() } + - indexes + - by_channel: ["channelId"] + +5. Background Processing: +- AI response generation runs asynchronously after each user message +- Uses OpenAI's GPT-4 to generate contextual responses +- Maintains conversation context using recent message history + + +### Implementation + +#### package.json +```typescript +{ + "name": "chat-app", + "description": "This example shows how to build a chat app without authentication.", + "version": "1.0.0", + "dependencies": { + "convex": "^1.31.2", + "openai": "^4.79.0" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} +``` + +#### tsconfig.json +```typescript +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "exclude": ["convex"], + "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"] +} +``` + +#### convex/index.ts +```typescript +import { + query, + mutation, + internalQuery, + internalMutation, + internalAction, +} from "./_generated/server"; +import { v } from "convex/values"; +import OpenAI from "openai"; +import { internal } from "./_generated/api"; + +/** + * Create a user with a given name. + */ +export const createUser = mutation({ + args: { + name: v.string(), + }, + returns: v.id("users"), + handler: async (ctx, args) => { + return await ctx.db.insert("users", { name: args.name }); + }, +}); + +/** + * Create a channel with a given name. + */ +export const createChannel = mutation({ + args: { + name: v.string(), + }, + returns: v.id("channels"), + handler: async (ctx, args) => { + return await ctx.db.insert("channels", { name: args.name }); + }, +}); + +/** + * List the 10 most recent messages from a channel in descending creation order. + */ +export const listMessages = query({ + args: { + channelId: v.id("channels"), + }, + returns: v.array( + v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + }), + ), + handler: async (ctx, args) => { + const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) + .order("desc") + .take(10); + return messages; + }, +}); + +/** + * Send a message to a channel and schedule a response from the AI. + */ +export const sendMessage = mutation({ + args: { + channelId: v.id("channels"), + authorId: v.id("users"), + content: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + const channel = await ctx.db.get(args.channelId); + if (!channel) { + throw new Error("Channel not found"); + } + const user = await ctx.db.get(args.authorId); + if (!user) { + throw new Error("User not found"); + } + await ctx.db.insert("messages", { + channelId: args.channelId, + authorId: args.authorId, + content: args.content, + }); + await ctx.scheduler.runAfter(0, internal.index.generateResponse, { + channelId: args.channelId, + }); + return null; + }, +}); + +const openai = new OpenAI(); + +export const generateResponse = internalAction({ + args: { + channelId: v.id("channels"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const context = await ctx.runQuery(internal.index.loadContext, { + channelId: args.channelId, + }); + const response = await openai.chat.completions.create({ + model: "gpt-4o", + messages: context, + }); + const content = response.choices[0].message.content; + if (!content) { + throw new Error("No content in response"); + } + await ctx.runMutation(internal.index.writeAgentResponse, { + channelId: args.channelId, + content, + }); + return null; + }, +}); + +export const loadContext = internalQuery({ + args: { + channelId: v.id("channels"), + }, + returns: v.array( + v.object({ + role: v.union(v.literal("user"), v.literal("assistant")), + content: v.string(), + }), + ), + handler: async (ctx, args) => { + const channel = await ctx.db.get(args.channelId); + if (!channel) { + throw new Error("Channel not found"); + } + const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) + .order("desc") + .take(10); + + const result = []; + for (const message of messages) { + if (message.authorId) { + const user = await ctx.db.get(message.authorId); + if (!user) { + throw new Error("User not found"); + } + result.push({ + role: "user" as const, + content: `${user.name}: ${message.content}`, + }); + } else { + result.push({ role: "assistant" as const, content: message.content }); + } + } + return result; + }, +}); + +export const writeAgentResponse = internalMutation({ + args: { + channelId: v.id("channels"), + content: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.insert("messages", { + channelId: args.channelId, + content: args.content, + }); + return null; + }, +}); +``` + +#### convex/schema.ts +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + channels: defineTable({ + name: v.string(), + }), + + users: defineTable({ + name: v.string(), + }), + + messages: defineTable({ + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + }).index("by_channel", ["channelId"]), +}); +``` + +#### convex/tsconfig.json +```typescript +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} +``` + +#### src/App.tsx +```typescript +export default function App() { + return
Hello World
; +} +``` + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2d9873e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,555 @@ +# Contributing to Sendable.ai + +First off, thank you for considering contributing to Sendable.ai! 🎉 + +The following is a set of guidelines for contributing to this project. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Workflow](#development-workflow) +- [Project Structure](#project-structure) +- [Coding Standards](#coding-standards) +- [Commit Guidelines](#commit-guidelines) +- [Pull Request Process](#pull-request-process) +- [Testing](#testing) +- [Documentation](#documentation) + +--- + +## Code of Conduct + +This project and everyone participating in it is governed by our commitment to fostering an open and welcoming environment. Please be respectful and professional in all interactions. + +### Our Standards + +- **Be Respectful**: Treat everyone with respect and kindness +- **Be Collaborative**: Work together towards the common goal +- **Be Patient**: Help others learn and grow +- **Accept Feedback**: Be open to constructive criticism +- **Focus on What's Best**: Prioritize the project and community + +--- + +## Getting Started + +### Prerequisites + +Before you begin, ensure you have: +- Node.js (v20.x or higher) +- pnpm (v9.x or higher) +- Git +- A Convex account ([Sign up free](https://convex.dev)) +- A code editor (VS Code recommended) + +### Initial Setup + +1. **Fork the Repository** + ```bash + # Click the "Fork" button on GitHub + ``` + +2. **Clone Your Fork** + ```bash + git clone https://github.com/hasnaintypes/sendable-ai.git + cd sendable-ai + ``` + +3. **Add Upstream Remote** + ```bash + git remote add upstream https://github.com/hasnaintypes/sendable-ai.git + ``` + +4. **Install Dependencies** + ```bash + pnpm install + ``` + +5. **Set Up Environment Variables** + ```bash + cp .env.example .env.local + # Edit .env.local with your credentials + ``` + +6. **Initialize Database** + ```bash + npx convex dev --once + ``` + +7. **Start Development Server** + ```bash + pnpm run dev + ``` + +--- + +## 🔄 Development Workflow + +### Branching Strategy + +We follow a feature-branch workflow: + +``` +main (production) + ↓ +develop (staging) + ↓ +feature/your-feature-name (your work) +``` + +### Creating a Feature Branch + +```bash +# Update your local main branch +git checkout main +git pull upstream main + +# Create and switch to a new branch +git checkout -b feature/your-feature-name + +# Or for bug fixes +git checkout -b fix/bug-description + +# Or for documentation +git checkout -b docs/what-you-are-documenting +``` + +### Branch Naming Convention + +- `feature/` - New features (e.g., `feature/lead-scoring`) +- `fix/` - Bug fixes (e.g., `fix/email-validation`) +- `docs/` - Documentation updates (e.g., `docs/api-reference`) +- `refactor/` - Code refactoring (e.g., `refactor/auth-flow`) +- `test/` - Adding tests (e.g., `test/campaign-creation`) +- `chore/` - Maintenance tasks (e.g., `chore/update-dependencies`) + +### Keep Your Branch Updated + +```bash +# Fetch latest changes from upstream +git fetch upstream + +# Rebase your branch onto upstream/main +git rebase upstream/main + +# If there are conflicts, resolve them and continue +git add . +git rebase --continue + +# Force push to your fork (only for your feature branches!) +git push origin feature/your-feature-name --force +``` + +--- + +## Project Structure + +``` +sendable-ai/ +├── convex/ # Backend (Convex) +│ ├── auth/ # Authentication mutations & queries +│ ├── betterAuth/ # Better Auth adapter & config +│ ├── emails/ # Email templates (React Email) +│ ├── leads/ # Lead management (future) +│ ├── campaigns/ # Campaign logic (future) +│ ├── _generated/ # Auto-generated files (don't edit) +│ └── schema.ts # Database schema definitions +├── src/ +│ ├── app/ # Next.js App Router +│ │ ├── (auth)/ # Protected routes +│ │ ├── (unauth)/ # Public routes +│ │ └── api/ # API routes +│ ├── components/ # React components +│ │ ├── auth/ # Authentication components +│ │ ├── layout/ # Layout components +│ │ ├── ui/ # shadcn/ui components +│ │ └── providers/ # Context providers +│ └── lib/ # Shared utilities +└── public/ # Static assets +``` + +### Key Files + +- `convex/schema.ts` - Database schema (modify carefully) +- `src/app/layout.tsx` - Root layout +- `src/lib/auth/client.tsx` - Client-side auth +- `convex/auth/helpers.ts` - Server-side auth + +--- + +## Coding Standards + +### TypeScript + +- **Always use TypeScript** - No plain JavaScript files +- **Define types explicitly** - Avoid `any` unless absolutely necessary +- **Use interfaces for objects** - Prefer `interface` over `type` for object shapes +- **Export types** - Make types reusable across files + +```typescript +// Good +interface Lead { + id: string; + email: string; + company: string; + status: "new" | "contacted" | "replied"; +} + +// Bad +const lead: any = { ... }; +``` + +### React Components + +- **Use functional components** - No class components +- **Use hooks** - Leverage React hooks for state and effects +- **One component per file** - Keep files focused +- **Export named components** - Avoid default exports for components + +```tsx +// Good +export function LeadCard({ lead }: { lead: Lead }) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + {/* Component content */} + + ); +} + +// Bad +export default ({ lead }) =>
...
+``` + +### Convex Functions + +- **Use clear function names** - Describe what the function does +- **Validate inputs** - Always check arguments +- **Use transactions** - For multi-document operations +- **Handle errors gracefully** - Return meaningful error messages + +```typescript +// Good +export const createLead = mutation({ + args: { + email: v.string(), + company: v.string(), + role: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // Validate email format + if (!args.email.includes("@")) { + throw new Error("Invalid email format"); + } + + // Check for duplicates + const existing = await ctx.db + .query("leads") + .withIndex("by_email", (q) => q.eq("email", args.email)) + .first(); + + if (existing) { + throw new Error("Lead already exists"); + } + + return await ctx.db.insert("leads", { + ...args, + createdAt: Date.now(), + status: "new", + }); + }, +}); +``` + +### Styling + +- **Use Tailwind CSS** - Utility-first approach +- **Follow shadcn/ui patterns** - For consistent component styling +- **Mobile-first** - Design for mobile, enhance for desktop +- **Dark mode support** - Use theme-aware classes + +```tsx +// Good +
+ +
+ +// Bad +
+ +
+``` + +### File Naming + +- **React components**: PascalCase (e.g., `LeadCard.tsx`) +- **Utilities/hooks**: camelCase (e.g., `useLeadFilter.ts`) +- **Convex functions**: camelCase (e.g., `mutations.ts`, `queries.ts`) +- **Pages**: lowercase with hyphens (e.g., `forget-password/page.tsx`) + +--- + +## Commit Guidelines + +We follow [Conventional Commits](https://www.conventionalcommits.org/) for clear commit history. + +### Commit Message Format + +``` +(): + + + +