diff --git a/.gitignore b/.gitignore index 485282a..45a32ef 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ next-env.d.ts /playwright/.cache/ .cache -.db \ No newline at end of file +.db +.turbo \ No newline at end of file diff --git a/.env.example b/backend/.env.example similarity index 100% rename from .env.example rename to backend/.env.example diff --git a/.prettierrc b/backend/.prettierrc similarity index 100% rename from .prettierrc rename to backend/.prettierrc diff --git a/CONTRIBUTING.md b/backend/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to backend/CONTRIBUTING.md diff --git a/LICENSE b/backend/LICENSE similarity index 100% rename from LICENSE rename to backend/LICENSE diff --git a/README.md b/backend/README.md similarity index 100% rename from README.md rename to backend/README.md diff --git a/backend/bun.lockb b/backend/bun.lockb new file mode 100755 index 0000000..aa9bef5 Binary files /dev/null and b/backend/bun.lockb differ diff --git a/backend/next.txt b/backend/next.txt new file mode 100644 index 0000000..482f60c --- /dev/null +++ b/backend/next.txt @@ -0,0 +1,7 @@ +* Increase time +* Write README +* Test with a new submission and approval +* Test with reject +* Integrate NEAR contract +* Build a small dashboard to see what there is to be approved and rejected? +* Tag all of the admins in the "Received" reply diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..24185a5 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,47 @@ +{ + "name": "backend", + "version": "0.0.1", + "packageManager": "bun@1.0.27", + "scripts": { + "build": "bun build ./src/index.ts --outdir=dist", + "start": "bun run dist/index.js", + "dev": "bun run --watch src/index.ts", + "test": "bun test", + "fmt": "prettier --write '**/*.{js,jsx,ts,tsx,json}'", + "fmt:check": "prettier --check '**/*.{js,jsx,ts,tsx,json}'" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/express": "^4.17.17", + "@types/jest": "^29.5.11", + "@types/node": "^20.10.6", + "@types/ora": "^3.2.0", + "jest": "^29.7.0", + "prettier": "^3.3.3", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "agent-twitter-client": "^0.0.16", + "cors": "^2.8.5", + "@types/cors": "^2.8.17", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "near-api-js": "^2.1.4", + "ora": "^8.1.1", + "winston": "^3.17.0", + "winston-console-format": "^1.0.8" + } +} diff --git a/src/config/admins.ts b/backend/src/config/admins.ts similarity index 100% rename from src/config/admins.ts rename to backend/src/config/admins.ts diff --git a/src/config/config.ts b/backend/src/config/config.ts similarity index 100% rename from src/config/config.ts rename to backend/src/config/config.ts diff --git a/src/index.ts b/backend/src/index.ts similarity index 57% rename from src/index.ts rename to backend/src/index.ts index a0bd62c..99582f7 100644 --- a/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,10 @@ import dotenv from "dotenv"; +import express from "express"; +import cors from "cors"; +import path from "path"; import { TwitterService } from "./services/twitter/client"; import { NearService } from "./services/near"; +import { db } from "./services/db"; import config from "./config/config"; import { logger, @@ -10,6 +14,52 @@ import { cleanup } from "./utils/logger"; +// Initialize Express +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Serve static frontend files in production +if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, '../frontend/dist'))); +} + +// API Routes +app.get('/api/submissions', (req, res) => { + try { + const status = req.query.status as "pending" | "approved" | "rejected"; + const submissions = status ? + db.getSubmissionsByStatus(status) : + db.getAllSubmissions(); + res.json(submissions); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch submissions' }); + } +}); + +app.get('/api/submissions/:tweetId', (req, res) => { + try { + const submission = db.getSubmission(req.params.tweetId); + if (!submission) { + res.status(404).json({ error: 'Submission not found' }); + return; + } + res.json(submission); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch submission' }); + } +}); + +// Serve frontend for all other routes in production +if (process.env.NODE_ENV === 'production') { + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); + }); +} + async function main() { try { // Load environment variables @@ -28,6 +78,12 @@ async function main() { await twitterService.initialize(); succeedSpinner('twitter-init', 'Twitter service initialized'); + // Start Express server + startSpinner('express', 'Starting Express server...'); + app.listen(PORT, () => { + succeedSpinner('express', `Express server running on port ${PORT}`); + }); + // Handle graceful shutdown process.on("SIGINT", async () => { startSpinner('shutdown', 'Shutting down gracefully...'); @@ -54,7 +110,7 @@ async function main() { } catch (error) { // Handle any initialization errors - ['env', 'near', 'twitter-init', 'twitter-mentions'].forEach(key => { + ['env', 'near', 'twitter-init', 'twitter-mentions', 'express'].forEach(key => { failSpinner(key, `Failed during ${key}`); }); logger.error('Startup', error); diff --git a/src/services/db/index.ts b/backend/src/services/db/index.ts similarity index 100% rename from src/services/db/index.ts rename to backend/src/services/db/index.ts diff --git a/src/services/near/index.ts b/backend/src/services/near/index.ts similarity index 100% rename from src/services/near/index.ts rename to backend/src/services/near/index.ts diff --git a/src/services/twitter/client.ts b/backend/src/services/twitter/client.ts similarity index 83% rename from src/services/twitter/client.ts rename to backend/src/services/twitter/client.ts index f3726c1..f69422f 100644 --- a/src/services/twitter/client.ts +++ b/backend/src/services/twitter/client.ts @@ -209,43 +209,62 @@ export class TwitterService { const userId = tweet.userId; if (!userId || !tweet.id) return; - // Get submission count from database instead of memory - const dailyCount = db.getDailySubmissionCount(userId); - - if (dailyCount >= this.DAILY_SUBMISSION_LIMIT) { - await this.replyToTweet( - tweet.id, - "You've reached your daily submission limit. Please try again tomorrow." - ); - logger.info(`User ${userId} has reached limit, replied to submission.`); + // Get the tweet being replied to + const inReplyToId = tweet.inReplyToStatusId; + if (!inReplyToId) { + logger.error(`Submission tweet ${tweet.id} is not a reply to another tweet`); return; } - const submission: TwitterSubmission = { - tweetId: tweet.id, - userId: userId, - content: tweet.text || "", - hashtags: tweet.hashtags || [], - status: "pending", - moderationHistory: [], - }; + try { + // Fetch the original tweet that's being submitted + const originalTweet = await this.client.getTweet(inReplyToId); + if (!originalTweet) { + logger.error(`Could not fetch original tweet ${inReplyToId}`); + return; + } - // Save submission to database - db.saveSubmission(submission); - // Increment submission count in database - db.incrementDailySubmissionCount(userId); + // Get submission count from database + const dailyCount = db.getDailySubmissionCount(userId); - // Send acknowledgment and save its ID - const acknowledgmentTweetId = await this.replyToTweet( - tweet.id, - "Successfully submitted to publicgoods.news!" - ); - - if (acknowledgmentTweetId) { - db.updateSubmissionAcknowledgment(tweet.id, acknowledgmentTweetId); - logger.info(`Successfully submitted. Sent reply: ${this.getTweetLink(acknowledgmentTweetId)}`) - } else { - logger.error(`Failed to acknowledge submission: ${this.getTweetLink(tweet.id, tweet.username)}`) + if (dailyCount >= this.DAILY_SUBMISSION_LIMIT) { + await this.replyToTweet( + tweet.id, + "You've reached your daily submission limit. Please try again tomorrow." + ); + logger.info(`User ${userId} has reached limit, replied to submission.`); + return; + } + + // Create submission using the original tweet's content + const submission: TwitterSubmission = { + tweetId: originalTweet.id!, // The tweet being submitted + userId: userId, // The user who submitted it + content: originalTweet.text || "", + hashtags: originalTweet.hashtags || [], + status: "pending", + moderationHistory: [], + }; + + // Save submission to database + db.saveSubmission(submission); + // Increment submission count in database + db.incrementDailySubmissionCount(userId); + + // Send acknowledgment and save its ID + const acknowledgmentTweetId = await this.replyToTweet( + tweet.id, // Reply to the submission tweet + "Successfully submitted to publicgoods.news!" + ); + + if (acknowledgmentTweetId) { + db.updateSubmissionAcknowledgment(originalTweet.id!, acknowledgmentTweetId); + logger.info(`Successfully submitted. Sent reply: ${this.getTweetLink(acknowledgmentTweetId)}`) + } else { + logger.error(`Failed to acknowledge submission: ${this.getTweetLink(tweet.id, tweet.username)}`) + } + } catch (error) { + logger.error(`Error handling submission for tweet ${tweet.id}:`, error); } } diff --git a/src/types/bun.d.ts b/backend/src/types/bun.d.ts similarity index 100% rename from src/types/bun.d.ts rename to backend/src/types/bun.d.ts diff --git a/src/types/index.ts b/backend/src/types/index.ts similarity index 100% rename from src/types/index.ts rename to backend/src/types/index.ts diff --git a/src/types/near.ts b/backend/src/types/near.ts similarity index 100% rename from src/types/near.ts rename to backend/src/types/near.ts diff --git a/src/types/twitter.ts b/backend/src/types/twitter.ts similarity index 100% rename from src/types/twitter.ts rename to backend/src/types/twitter.ts diff --git a/src/utils/cache.ts b/backend/src/utils/cache.ts similarity index 100% rename from src/utils/cache.ts rename to backend/src/utils/cache.ts diff --git a/src/utils/logger.ts b/backend/src/utils/logger.ts similarity index 100% rename from src/utils/logger.ts rename to backend/src/utils/logger.ts diff --git a/tsconfig.json b/backend/tsconfig.json similarity index 100% rename from tsconfig.json rename to backend/tsconfig.json diff --git a/bun.lockb b/bun.lockb index 9694d22..7225c14 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100755 index 0000000..218578d Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dd74230 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + +
+ + +{error}
+ +Tweet ID: {submission.tweetId}
+{submission.content}
++ Category: {submission.category} +
+ )} + + {submission.description && ( ++ Description: {submission.description} +
+ )} + + {submission.moderationHistory.length > 0 && ( +