diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6be9e1f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,81 @@ +# flyctl launch added from .gitignore +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +**/.pnp.js +**/.yarn/install-state.gz +.unlighthouse + +# testing +coverage + +# next.js +.next +out + +# production +build +**/**/dist + +# misc +**/.DS_Store +**/*.pem + +# debug +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* + +# local env files +**/.env*.local +**/.env + +# vercel +**/.vercel + +# typescript +**/*.tsbuildinfo +**/next-env.d.ts +test-results +playwright-report +blob-report +playwright/.cache + +**/.cache +**/.db +**/.turbo + +# flyctl launch added from frontend/.gitignore +# Logs +frontend/**/logs +frontend/**/*.log +frontend/**/npm-debug.log* +frontend/**/yarn-debug.log* +frontend/**/yarn-error.log* +frontend/**/pnpm-debug.log* +frontend/**/lerna-debug.log* + +frontend/node_modules +frontend/dist +frontend/**/dist-ssr +frontend/**/*.local + +# Editor directories and files +frontend/**/.vscode/* +!frontend/**/.vscode/extensions.json +frontend/**/.idea +frontend/**/.DS_Store +frontend/**/*.suo +frontend/**/*.ntvs* +frontend/**/*.njsproj +frontend/**/*.sln +frontend/**/*.sw? + +# flyctl launch added from frontend/node_modules/tailwindcss/stubs/.gitignore +!frontend/node_modules/tailwindcss/stubs/**/* + +# flyctl launch added from node_modules/tailwindcss/stubs/.gitignore +!node_modules/tailwindcss/stubs/**/* +fly.toml diff --git a/.env.example b/.env.example deleted file mode 100644 index 97d2b1f..0000000 --- a/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# Twitter API Credentials -TWITTER_USERNAME=your_twitter_username -TWITTER_PASSWORD=your_twitter_password -TWITTER_EMAIL=your_twitter_email - -# NEAR Configuration -NEAR_NETWORK_ID=testnet -NEAR_LIST_CONTRACT=your_list_contract_name -NEAR_SIGNER_ACCOUNT=your_signer_account -NEAR_SIGNER_PRIVATE_KEY=your_signer_private_key - -# Environment -NODE_ENV=development diff --git a/.gitignore b/.gitignore index 485282a..f0db091 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # production /build +**/dist # misc .DS_Store @@ -42,4 +43,5 @@ next-env.d.ts /playwright/.cache/ .cache -.db \ No newline at end of file +.db +.turbo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd28374 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +FROM oven/bun as deps + +WORKDIR /app + +# Copy package files for all workspaces +COPY package.json bun.lockb turbo.json ./ +COPY frontend/package.json ./frontend/ +COPY backend/package.json ./backend/ + +# Install dependencies +RUN bun install + +# Build stage +FROM oven/bun as builder +WORKDIR /app + +# Set NODE_ENV for build process +ENV NODE_ENV="production" + +# Copy all files from deps stage including node_modules +COPY --from=deps /app ./ + +# Copy source code +COPY . . + +# Build both frontend and backend +RUN bun run build + +# Production stage +FROM oven/bun as production +WORKDIR /app + +# Create directory for mount with correct permissions +RUN mkdir -p /.data/db /.data/cache && \ + chown -R bun:bun /.data + +# Copy only necessary files from builder +COPY --from=builder --chown=bun:bun /app/package.json /app/bun.lockb /app/turbo.json ./ +COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules +COPY --from=builder --chown=bun:bun /app/frontend/dist ./frontend/dist +COPY --from=builder --chown=bun:bun /app/backend/dist ./backend/dist + +# Set environment variables +ENV DATABASE_URL="file:/.data/db/sqlite.db" +ENV CACHE_DIR="/.data/cache" +ENV NODE_ENV="production" + +# Expose the port +EXPOSE 3000 + +# Start the application using the production start script +CMD ["bun", "run", "start"] diff --git a/README.md b/README.md index e7a3566..80d88e7 100644 --- a/README.md +++ b/README.md @@ -16,32 +16,68 @@
Table of Contents +- [Project Structure](#project-structure) + - [Monorepo Overview](#monorepo-overview) + - [Key Components](#key-components) - [Getting Started](#getting-started) - [Installing dependencies](#installing-dependencies) - [Environment Setup](#environment-setup) - [Running the app](#running-the-app) - [Building for production](#building-for-production) + - [Deploying to Fly.io](#deploying-to-flyio) - [Running tests](#running-tests) - [Configuration](#configuration) - [Twitter Setup](#twitter-setup) - [Admin Configuration](#admin-configuration) - - [NEAR Network Setup](#near-network-setup) - [Bot Functionality](#bot-functionality) - [Submission Process](#submission-process) - [Moderation System](#moderation-system) - [Rate Limiting](#rate-limiting) +- [Customization](#customization) + - [Frontend Customization](#frontend-customization) + - [Backend Customization](#backend-customization) - [Contributing](#contributing)
+## Project Structure + +### Monorepo Overview + +This project uses a monorepo structure managed with [Turborepo](https://turbo.build/repo) for efficient build orchestration: + +```bash +public-goods-news/ +├── frontend/ # React frontend application +├── backend/ # Bun-powered backend service +├── package.json # Root package.json for shared dependencies +└── turbo.json # Turborepo configuration +``` + +### Key Components + +- **Frontend** ([Documentation](./frontend/README.md)) + - React-based web interface + - Built with Vite and Tailwind CSS + - Handles user interactions and submissions + +- **Backend** ([Documentation](./backend/README.md)) + - Bun runtime for high performance + - Twitter bot functionality + - API endpoints for frontend + ## Getting Started ### Installing dependencies +The monorepo uses Bun for package management. Install all dependencies with: + ```bash bun install ``` +This will install dependencies for both frontend and backend packages. + ### Environment Setup Copy the environment template and configure your credentials: @@ -57,28 +93,72 @@ Required environment variables: TWITTER_USERNAME=your_twitter_username TWITTER_PASSWORD=your_twitter_password TWITTER_EMAIL=your_twitter_email - -# NEAR Configuration -NEAR_NETWORK_ID=testnet -NEAR_LIST_CONTRACT=your_list_contract_name -NEAR_SIGNER_ACCOUNT=your_signer_account -NEAR_SIGNER_PRIVATE_KEY=your_signer_private_key ``` ### Running the app -First, run the development server: +Start both frontend and backend development servers: ```bash bun run dev ``` +This will launch: + +- Frontend at http://localhost:5173 +- Backend at http://localhost:3000 + ### Building for production +Build all packages: + ```bash bun run build ``` +### Deploying to Fly.io + +The backend service can be deployed to Fly.io with SQLite support. First, install the Fly CLI: + +```bash +# macOS +brew install flyctl + +# Windows +powershell -Command "iwr https://fly.io/install.ps1 -useb | iex" + +# Linux +curl -L https://fly.io/install.sh | sh +``` + +Then sign up and authenticate: + +```bash +fly auth signup +# or +fly auth login +``` + +Deploy the application using the provided npm scripts: + +```bash +# Initialize Fly.io app +bun run deploy:init + +# Create persistent volumes for SQLite and cache +bun run deploy:volumes + +# Deploy the application +bun run deploy +``` + +The deployment configuration includes: + +- Persistent storage for SQLite database +- Cache directory support +- Auto-scaling configuration +- HTTPS enabled by default + ### Running tests ```bash @@ -103,7 +183,7 @@ It will use these credentials to login and cache cookies via [agent-twitter-clie ### Admin Configuration -Admins are Twitter accounts that have moderation privileges. Configure admin accounts in `src/config/admins.ts`: +Admins are Twitter accounts that have moderation privileges. Configure admin accounts in `backend/src/config/admins.ts`: ```typescript export const ADMIN_ACCOUNTS: string[] = [ @@ -113,22 +193,13 @@ export const ADMIN_ACCOUNTS: string[] = [ ] ``` -Admin accounts are automatically tagged in submission acknolwedgements and can: +Admin accounts are automatically tagged in submission acknowledgements and can: - Approve submissions using the `#approve` hashtag - Reject submissions using the `#reject` hashtag Only the first moderation will be recorded. -### NEAR Network Setup - -Configure NEAR network settings in your `.env` file: - -```env -NEAR_NETWORK_ID=testnet -NEAR_CONTRACT_NAME=your_contract_name -``` - ## Bot Functionality ### Submission Process @@ -155,6 +226,48 @@ To maintain quality: - Rate limits reset daily - Exceeding the limit results in a notification tweet +## Customization + +### Frontend Customization + +The frontend can be customized in several ways: + +1. **Styling** + - Modify `frontend/tailwind.config.js` for theme customization + - Update global styles in `frontend/src/index.css` + - Component-specific styles in respective component files + +2. **Components** + - Add new components in `frontend/src/components/` + - Modify existing components for different layouts or functionality + +3. **Configuration** + - Update API endpoints in environment variables + - Modify build settings in `vite.config.ts` + +See the [Frontend README](./frontend/README.md) for detailed customization options. + +### Backend Customization + +The backend service can be extended and customized: + +1. **Services** + - Add new services in `backend/src/services/` + - Modify existing services for different functionality + - Extend API endpoints as needed + +2. **Configuration** + - Update environment variables for different integrations + - Modify admin settings in `src/config/` + - Adjust rate limits and other constraints + +3. **Integration** + - Add new blockchain integrations + - Extend social media support + - Implement additional APIs + +See the [Backend README](./backend/README.md) for detailed customization options. + ## Contributing Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..690bbe8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +# Twitter API Credentials +TWITTER_USERNAME=your_twitter_username +TWITTER_PASSWORD=your_twitter_password +TWITTER_EMAIL=your_twitter_email + +# Environment +NODE_ENV=development diff --git a/LICENSE b/backend/LICENSE similarity index 100% rename from LICENSE rename to backend/LICENSE diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a021c6d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,125 @@ + + + + + +
+ +

Public Goods News Backend

+ +

+ TypeScript-based backend service for the Public Goods News Curation platform +

+ +
+ +
+ Table of Contents + +- [Architecture Overview](#architecture-overview) + - [Tech Stack](#tech-stack) + - [Service Architecture](#service-architecture) +- [Key Components](#key-components) + - [Database Service](#database-service) + - [Twitter Service](#twitter-service) +- [Development](#development) + - [Prerequisites](#prerequisites) + - [Local Setup](#local-setup) +- [API Documentation](#api-documentation) + +
+ +## Architecture Overview + +### Tech Stack + +The backend is built with modern technologies chosen for their performance, developer experience, and ecosystem: + +- **Runtime**: [Bun](https://bun.sh) + - Chosen for its exceptional performance and built-in TypeScript support + - Provides native testing capabilities and package management + - Offers excellent developer experience with fast startup times + +- **Language**: TypeScript + - Ensures type safety and better developer experience + - Enables better code organization and maintainability + - Provides excellent IDE support and code navigation + +### Service Architecture + +The backend follows a modular service-based architecture: + +``` +src/ +├── config/ # Configuration management +├── services/ # Core service implementations +├── types/ # TypeScript type definitions +└── utils/ # Shared utilities +``` + +## Key Components + +### Database Service + +Located in `src/services/db`, handles: + +- Data persistence +- Caching layer +- Query optimization + +### Twitter Service + +Twitter integration (`src/services/twitter`) manages: + +- Authentication +- Tweet interactions +- Rate limiting +- User management + +## Development + +### Prerequisites + +- Bun runtime installed +- Node.js 18+ (for some dev tools) +- Twitter API credentials + +### Local Setup + +1. Install dependencies: + +```bash +bun install +``` + +2. Configure environment: + +```bash +cp .env.example .env +``` + +3. Start development server: + +```bash +bun run dev +``` + +## API Documentation + +The backend exposes several endpoints for frontend interaction: + +- `POST /submit`: Submit new content +- `GET /submissions`: Retrieve submission list +- `POST /moderate`: Handle moderation actions + +See the [Frontend README](../frontend/README.md) for integration details. + +
+ +Near Builders + +
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..007e25a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "backend", + "version": "0.0.1", + "packageManager": "bun@1.0.27", + "type": "module", + "scripts": { + "build": "bun build ./src/index.ts --target=bun --outdir=dist --format=esm", + "start": "bun run dist/index.js", + "dev": "bun run --watch src/index.ts" + }, + "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", + "bun-types": "^1.1.40", + "jest": "^29.7.0", + "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", + "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 86% rename from src/config/admins.ts rename to backend/src/config/admins.ts index d1003ba..8c3deef 100644 --- a/src/config/admins.ts +++ b/backend/src/config/admins.ts @@ -1,5 +1,5 @@ export const ADMIN_ACCOUNTS: string[] = [ - "elliot_braem" + "elliot_braem", // Add admin Twitter handles here (without @) // Example: "TwitterDev" ]; diff --git a/backend/src/config/config.ts b/backend/src/config/config.ts new file mode 100644 index 0000000..8125d81 --- /dev/null +++ b/backend/src/config/config.ts @@ -0,0 +1,27 @@ +import { AppConfig } from "../types"; + +const config: AppConfig = { + twitter: { + username: process.env.TWITTER_USERNAME!, + password: process.env.TWITTER_PASSWORD!, + email: process.env.TWITTER_EMAIL!, + }, + environment: + (process.env.NODE_ENV as "development" | "production" | "test") || + "development", +}; + +export function validateEnv() { + // Validate required Twitter credentials + if ( + !process.env.TWITTER_USERNAME || + !process.env.TWITTER_PASSWORD || + !process.env.TWITTER_EMAIL + ) { + throw new Error( + "Missing required Twitter credentials. Please ensure TWITTER_USERNAME, TWITTER_PASSWORD, and TWITTER_EMAIL are set in your environment variables.", + ); + } +} + +export default config; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..ede15df --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,175 @@ +import { ServerWebSocket } from "bun"; +import dotenv from "dotenv"; +import path from "path"; +import config, { validateEnv } from "./config/config"; +import { db } from "./services/db"; +import { TwitterService } from "./services/twitter/client"; +import { + cleanup, + failSpinner, + logger, + startSpinner, + succeedSpinner, +} from "./utils/logger"; + +const PORT = Number(process.env.PORT) || 3001; // Use different port for testing + +// Store active WebSocket connections +const activeConnections = new Set(); + +// Broadcast to all connected clients +export function broadcastUpdate(data: unknown) { + const message = JSON.stringify(data); + activeConnections.forEach((ws) => { + try { + ws.send(message); + } catch (error) { + logger.error("Error broadcasting to WebSocket client:", error); + activeConnections.delete(ws); + } + }); +} + +async function main() { + try { + // Load environment variables + startSpinner("env", "Loading environment variables..."); + dotenv.config(); + validateEnv(); + succeedSpinner("env", "Environment variables loaded"); + + // Initialize services + startSpinner("server", "Starting server..."); + + const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + + // WebSocket upgrade + if (url.pathname === "/ws") { + if (server.upgrade(req)) { + return; + } + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // API Routes + if (url.pathname.startsWith("/api")) { + try { + if (url.pathname === "/api/submissions") { + const status = url.searchParams.get("status") as + | "pending" + | "approved" + | "rejected" + | null; + const submissions = status + ? db.getSubmissionsByStatus(status) + : db.getAllSubmissions(); + return Response.json(submissions); + } + + const match = url.pathname.match(/^\/api\/submissions\/(.+)$/); + if (match) { + const tweetId = match[1]; + const submission = db.getSubmission(tweetId); + if (!submission) { + return Response.json( + { error: "Submission not found" }, + { status: 404 }, + ); + } + return Response.json(submission); + } + } catch (error) { + return Response.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } + } + + // Serve static frontend files in production only + if (process.env.NODE_ENV === "production") { + const filePath = url.pathname === "/" ? "/index.html" : url.pathname; + const file = Bun.file( + path.join(__dirname, "../../frontend/dist", filePath), + ); + if (await file.exists()) { + return new Response(file); + } + // Fallback to index.html for client-side routing + return new Response( + Bun.file(path.join(__dirname, "../../frontend/dist/index.html")), + ); + } + + return new Response("Not found", { status: 404 }); + }, + websocket: { + open: (ws: ServerWebSocket) => { + activeConnections.add(ws); + logger.debug( + `WebSocket client connected. Total connections: ${activeConnections.size}`, + ); + }, + close: (ws: ServerWebSocket) => { + activeConnections.delete(ws); + logger.debug( + `WebSocket client disconnected. Total connections: ${activeConnections.size}`, + ); + }, + message: (ws: ServerWebSocket, message: string | Buffer) => { + // we don't care about two-way connection yet + }, + }, + }); + + succeedSpinner("server", `Server running on port ${PORT}`); + + // Initialize Twitter service after server is running + startSpinner("twitter-init", "Initializing Twitter service..."); + const twitterService = new TwitterService(config.twitter); + await twitterService.initialize(); + succeedSpinner("twitter-init", "Twitter service initialized"); + + // Handle graceful shutdown + process.on("SIGINT", async () => { + startSpinner("shutdown", "Shutting down gracefully..."); + try { + await twitterService.stop(); + succeedSpinner("shutdown", "Shutdown complete"); + process.exit(0); + } catch (error) { + failSpinner("shutdown", "Error during shutdown"); + logger.error("Shutdown", error); + process.exit(1); + } + }); + + logger.info("🚀 Bot is running and ready for events", { + twitterEnabled: true, + websocketEnabled: true, + }); + + // Start checking for mentions + startSpinner("twitter-mentions", "Starting mentions check..."); + await twitterService.startMentionsCheck(); + succeedSpinner("twitter-mentions", "Mentions check started"); + } catch (error) { + // Handle any initialization errors + ["env", "twitter-init", "twitter-mentions", "server"].forEach((key) => { + failSpinner(key, `Failed during ${key}`); + }); + logger.error("Startup", error); + cleanup(); + process.exit(1); + } +} + +// Start the application +logger.info("Starting Public Goods News Bot..."); +main().catch((error) => { + logger.error("Unhandled Exception", error); + process.exit(1); +}); diff --git a/src/services/db/index.ts b/backend/src/services/db/index.ts similarity index 68% rename from src/services/db/index.ts rename to backend/src/services/db/index.ts index 5d1af48..3f8dce0 100644 --- a/src/services/db/index.ts +++ b/backend/src/services/db/index.ts @@ -1,13 +1,15 @@ import { Database } from "bun:sqlite"; import { TwitterSubmission, Moderation } from "../../types"; import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; +import { broadcastUpdate } from "../../index"; export class DatabaseService { private db: Database; - private static readonly DB_DIR = ".db"; - private static readonly DB_PATH = join(DatabaseService.DB_DIR, "submissions.sqlite"); + private static readonly DB_PATH = + process.env.DATABASE_URL?.replace("file:", "") || + join(".db", "submissions.sqlite"); constructor() { this.ensureDbDirectory(); @@ -15,9 +17,18 @@ export class DatabaseService { this.initialize(); } + private notifyUpdate() { + const submissions = this.getAllSubmissions(); + broadcastUpdate({ + type: "update", + data: submissions, + }); + } + private ensureDbDirectory() { - if (!existsSync(DatabaseService.DB_DIR)) { - mkdir(DatabaseService.DB_DIR, { recursive: true }); + const dbDir = dirname(DatabaseService.DB_PATH); + if (!existsSync(dbDir)) { + mkdir(dbDir, { recursive: true }); } } @@ -27,6 +38,7 @@ export class DatabaseService { CREATE TABLE IF NOT EXISTS submissions ( tweet_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + username TEXT NOT NULL, content TEXT NOT NULL, hashtags TEXT NOT NULL, category TEXT, @@ -34,7 +46,7 @@ export class DatabaseService { status TEXT NOT NULL DEFAULT 'pending', acknowledgment_tweet_id TEXT, moderation_response_tweet_id TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL ) `); @@ -79,25 +91,39 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_acknowledgment_tweet_id ON submissions(acknowledgment_tweet_id) `); + + // Add new columns if they don't exist + try { + this.db.run( + `ALTER TABLE submissions ADD COLUMN username TEXT NOT NULL DEFAULT ''`, + ); + } catch (e) { + // Column might already exist + } } saveSubmission(submission: TwitterSubmission): void { const stmt = this.db.prepare(` INSERT INTO submissions ( - tweet_id, user_id, content, hashtags, category, description, status, acknowledgment_tweet_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + tweet_id, user_id, username, content, hashtags, category, description, status, + acknowledgment_tweet_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( submission.tweetId, submission.userId, + submission.username, submission.content, JSON.stringify(submission.hashtags), submission.category || null, submission.description || null, submission.status, - submission.acknowledgmentTweetId || null + submission.acknowledgmentTweetId || null, + submission.createdAt, ); + + this.notifyUpdate(); } saveModerationAction(moderation: Moderation): void { @@ -111,21 +137,35 @@ export class DatabaseService { moderation.tweetId, moderation.adminId, moderation.action, - moderation.timestamp.toISOString() + moderation.timestamp.toISOString(), ); + + this.notifyUpdate(); } - updateSubmissionStatus(tweetId: string, status: TwitterSubmission['status'], moderationResponseTweetId: string): void { - this.db.prepare(` + updateSubmissionStatus( + tweetId: string, + status: TwitterSubmission["status"], + moderationResponseTweetId: string, + ): void { + this.db + .prepare( + ` UPDATE submissions SET status = ?, moderation_response_tweet_id = ? WHERE tweet_id = ? - `).run(status, moderationResponseTweetId, tweetId); + `, + ) + .run(status, moderationResponseTweetId, tweetId); + + this.notifyUpdate(); } getSubmission(tweetId: string): TwitterSubmission | null { - const submission = this.db.prepare(` + const submission = this.db + .prepare( + ` SELECT s.*, GROUP_CONCAT( json_object( 'adminId', m.admin_id, @@ -138,13 +178,16 @@ export class DatabaseService { LEFT JOIN moderation_history m ON s.tweet_id = m.tweet_id WHERE s.tweet_id = ? GROUP BY s.tweet_id - `).get(tweetId) as any; + `, + ) + .get(tweetId) as any; if (!submission) return null; return { tweetId: submission.tweet_id, userId: submission.user_id, + username: submission.username, content: submission.content, hashtags: JSON.parse(submission.hashtags), category: submission.category, @@ -152,17 +195,22 @@ export class DatabaseService { status: submission.status, acknowledgmentTweetId: submission.acknowledgment_tweet_id, moderationResponseTweetId: submission.moderation_response_tweet_id, - moderationHistory: submission.moderation_history + createdAt: submission.created_at, + moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, - timestamp: new Date(m.timestamp) + timestamp: new Date(m.timestamp), })) - : [] + : [], }; } - getSubmissionByAcknowledgmentTweetId(acknowledgmentTweetId: string): TwitterSubmission | null { - const submission = this.db.prepare(` + getSubmissionByAcknowledgmentTweetId( + acknowledgmentTweetId: string, + ): TwitterSubmission | null { + const submission = this.db + .prepare( + ` SELECT s.*, GROUP_CONCAT( json_object( 'adminId', m.admin_id, @@ -175,13 +223,16 @@ export class DatabaseService { LEFT JOIN moderation_history m ON s.tweet_id = m.tweet_id WHERE s.acknowledgment_tweet_id = ? GROUP BY s.tweet_id - `).get(acknowledgmentTweetId) as any; + `, + ) + .get(acknowledgmentTweetId) as any; if (!submission) return null; return { tweetId: submission.tweet_id, userId: submission.user_id, + username: submission.username, content: submission.content, hashtags: JSON.parse(submission.hashtags), category: submission.category, @@ -189,17 +240,20 @@ export class DatabaseService { status: submission.status, acknowledgmentTweetId: submission.acknowledgment_tweet_id, moderationResponseTweetId: submission.moderation_response_tweet_id, - moderationHistory: submission.moderation_history + createdAt: submission.created_at, + moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, - timestamp: new Date(m.timestamp) + timestamp: new Date(m.timestamp), })) - : [] + : [], }; } getAllSubmissions(): TwitterSubmission[] { - const submissions = this.db.prepare(` + const submissions = this.db + .prepare( + ` SELECT s.*, GROUP_CONCAT( json_object( 'adminId', m.admin_id, @@ -211,11 +265,14 @@ export class DatabaseService { FROM submissions s LEFT JOIN moderation_history m ON s.tweet_id = m.tweet_id GROUP BY s.tweet_id - `).all() as any[]; + `, + ) + .all() as any[]; - return submissions.map(submission => ({ + return submissions.map((submission) => ({ tweetId: submission.tweet_id, userId: submission.user_id, + username: submission.username, content: submission.content, hashtags: JSON.parse(submission.hashtags), category: submission.category, @@ -223,17 +280,22 @@ export class DatabaseService { status: submission.status, acknowledgmentTweetId: submission.acknowledgment_tweet_id, moderationResponseTweetId: submission.moderation_response_tweet_id, - moderationHistory: submission.moderation_history + createdAt: submission.created_at, + moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, - timestamp: new Date(m.timestamp) + timestamp: new Date(m.timestamp), })) - : [] + : [], })); } - getSubmissionsByStatus(status: TwitterSubmission['status']): TwitterSubmission[] { - const submissions = this.db.prepare(` + getSubmissionsByStatus( + status: TwitterSubmission["status"], + ): TwitterSubmission[] { + const submissions = this.db + .prepare( + ` SELECT s.*, GROUP_CONCAT( json_object( 'adminId', m.admin_id, @@ -246,11 +308,14 @@ export class DatabaseService { LEFT JOIN moderation_history m ON s.tweet_id = m.tweet_id WHERE s.status = ? GROUP BY s.tweet_id - `).all(status) as any[]; + `, + ) + .all(status) as any[]; - return submissions.map(submission => ({ + return submissions.map((submission) => ({ tweetId: submission.tweet_id, userId: submission.user_id, + username: submission.username, content: submission.content, hashtags: JSON.parse(submission.hashtags), category: submission.category, @@ -258,38 +323,49 @@ export class DatabaseService { status: submission.status, acknowledgmentTweetId: submission.acknowledgment_tweet_id, moderationResponseTweetId: submission.moderation_response_tweet_id, - moderationHistory: submission.moderation_history + createdAt: submission.created_at, + moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, - timestamp: new Date(m.timestamp) + timestamp: new Date(m.timestamp), })) - : [] + : [], })); } // Rate limiting methods getDailySubmissionCount(userId: string): number { - const today = new Date().toISOString().split('T')[0]; - + const today = new Date().toISOString().split("T")[0]; + // Clean up old entries first - this.db.prepare(` + this.db + .prepare( + ` DELETE FROM submission_counts WHERE last_reset_date < ? - `).run(today); + `, + ) + .run(today); - const result = this.db.prepare(` + const result = this.db + .prepare( + ` SELECT count FROM submission_counts WHERE user_id = ? AND last_reset_date = ? - `).get(userId, today) as { count: number } | undefined; + `, + ) + .get(userId, today) as { count: number } | undefined; return result?.count || 0; } incrementDailySubmissionCount(userId: string): void { - const today = new Date().toISOString().split('T')[0]; + const today = new Date().toISOString().split("T")[0]; - this.db.prepare(` + this.db + .prepare( + ` INSERT INTO submission_counts (user_id, count, last_reset_date) VALUES (?, 1, ?) ON CONFLICT(user_id) DO UPDATE SET @@ -298,36 +374,55 @@ export class DatabaseService { ELSE count + 1 END, last_reset_date = ? - `).run(userId, today, today, today); + `, + ) + .run(userId, today, today, today); } // Last checked tweet ID methods getLastCheckedTweetId(): string | null { - const result = this.db.prepare(` + const result = this.db + .prepare( + ` SELECT value FROM twitter_state WHERE key = 'last_checked_tweet_id' - `).get() as { value: string } | undefined; + `, + ) + .get() as { value: string } | undefined; return result?.value || null; } saveLastCheckedTweetId(tweetId: string): void { - this.db.prepare(` + this.db + .prepare( + ` INSERT INTO twitter_state (key, value, updated_at) VALUES ('last_checked_tweet_id', ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP - `).run(tweetId, tweetId); + `, + ) + .run(tweetId, tweetId); } - updateSubmissionAcknowledgment(tweetId: string, acknowledgmentTweetId: string): void { - this.db.prepare(` + updateSubmissionAcknowledgment( + tweetId: string, + acknowledgmentTweetId: string, + ): void { + this.db + .prepare( + ` UPDATE submissions SET acknowledgment_tweet_id = ? WHERE tweet_id = ? - `).run(acknowledgmentTweetId, tweetId); + `, + ) + .run(acknowledgmentTweetId, tweetId); + + this.notifyUpdate(); } } diff --git a/src/services/twitter/client.ts b/backend/src/services/twitter/client.ts similarity index 61% rename from src/services/twitter/client.ts rename to backend/src/services/twitter/client.ts index f3726c1..a7f1313 100644 --- a/src/services/twitter/client.ts +++ b/backend/src/services/twitter/client.ts @@ -1,14 +1,18 @@ import { Scraper, SearchMode, Tweet } from "agent-twitter-client"; -import { TwitterSubmission, Moderation, TwitterConfig } from "../../types/twitter"; -import { ADMIN_ACCOUNTS } from "../../config/admins"; +import { + TwitterSubmission, + Moderation, + TwitterConfig, +} from "../../types/twitter"; import { logger } from "../../utils/logger"; import { db } from "../db"; -import { +import { TwitterCookie, ensureCacheDirectory, getCachedCookies, cacheCookies, } from "../../utils/cache"; +import { ADMIN_ACCOUNTS } from "config/admins"; export class TwitterService { private client: Scraper; @@ -16,7 +20,7 @@ export class TwitterService { private twitterUsername: string; private config: TwitterConfig; private isInitialized = false; - private checkInterval: NodeJS.Timeout | null = null; + private checkInterval: NodeJS.Timer | null = null; private lastCheckedTweetId: string | null = null; private adminIdCache: Map = new Map(); @@ -29,9 +33,11 @@ export class TwitterService { private async setCookiesFromArray(cookiesArray: TwitterCookie[]) { const cookieStrings = cookiesArray.map( (cookie) => - `${cookie.key}=${cookie.value}; Domain=${cookie.domain}; Path=${cookie.path}; ${cookie.secure ? "Secure" : "" - }; ${cookie.httpOnly ? "HttpOnly" : ""}; SameSite=${cookie.sameSite || "Lax" - }` + `${cookie.key}=${cookie.value}; Domain=${cookie.domain}; Path=${cookie.path}; ${ + cookie.secure ? "Secure" : "" + }; ${cookie.httpOnly ? "HttpOnly" : ""}; SameSite=${ + cookie.sameSite || "Lax" + }`, ); await this.client.setCookies(cookieStrings); } @@ -67,7 +73,7 @@ export class TwitterService { this.lastCheckedTweetId = db.getLastCheckedTweetId(); // Try to login with retries - logger.info('Attempting Twitter login...'); + logger.info("Attempting Twitter login..."); while (true) { try { await this.client.login( @@ -83,7 +89,7 @@ export class TwitterService { break; } } catch (error) { - logger.error('Failed to login to Twitter, retrying...', error); + logger.error("Failed to login to Twitter, retrying...", error); } // Wait before retrying @@ -94,9 +100,9 @@ export class TwitterService { await this.initializeAdminIds(); this.isInitialized = true; - logger.info('Successfully logged in to Twitter'); + logger.info("Successfully logged in to Twitter"); } catch (error) { - logger.error('Failed to initialize Twitter client:', error); + logger.error("Failed to initialize Twitter client:", error); throw error; } } @@ -115,7 +121,9 @@ export class TwitterService { `@${this.twitterUsername}`, BATCH_SIZE, SearchMode.Latest, - allNewTweets.length > 0 ? allNewTweets[allNewTweets.length - 1].id : undefined + allNewTweets.length > 0 + ? allNewTweets[allNewTweets.length - 1].id + : undefined, ) ).tweets; @@ -124,8 +132,11 @@ export class TwitterService { // Check if any tweet in this batch is older than or equal to our last checked ID for (const tweet of batch) { if (!tweet.id) continue; - - if (!this.lastCheckedTweetId || BigInt(tweet.id) > BigInt(this.lastCheckedTweetId)) { + + if ( + !this.lastCheckedTweetId || + BigInt(tweet.id) > BigInt(this.lastCheckedTweetId) + ) { allNewTweets.push(tweet); } else { foundOldTweet = true; @@ -136,50 +147,54 @@ export class TwitterService { if (batch.length < BATCH_SIZE) break; // Last batch was partial, no more to fetch attempts++; } catch (error) { - logger.error('Error fetching mentions batch:', error); + logger.error("Error fetching mentions batch:", error); break; } } // Sort all fetched tweets by ID (chronologically) return allNewTweets.sort((a, b) => { - const aId = BigInt(a.id || '0'); - const bId = BigInt(b.id || '0'); + const aId = BigInt(a.id || "0"); + const bId = BigInt(b.id || "0"); return aId > bId ? 1 : aId < bId ? -1 : 0; }); } async startMentionsCheck() { - logger.info('Listening for mentions...'); - + logger.info("Listening for mentions..."); + // Check mentions every minute this.checkInterval = setInterval(async () => { if (!this.isInitialized) return; try { - logger.info('Checking mentions...'); - + logger.info("Checking mentions..."); + const newTweets = await this.fetchAllNewMentions(); if (newTweets.length === 0) { - logger.info('No new mentions'); + logger.info("No new mentions"); } else { logger.info(`Found ${newTweets.length} new mentions`); // Process new tweets for (const tweet of newTweets) { if (!tweet.id) continue; - + try { if (this.isSubmission(tweet)) { - logger.info(`Received new submission: ${this.getTweetLink(tweet.id, tweet.username)}`); + logger.info( + `Received new submission: ${this.getTweetLink(tweet.id, tweet.username)}`, + ); await this.handleSubmission(tweet); } else if (this.isModeration(tweet)) { - logger.info(`Received new moderation: ${this.getTweetLink(tweet.id, tweet.username)}`); + logger.info( + `Received new moderation: ${this.getTweetLink(tweet.id, tweet.username)}`, + ); await this.handleModeration(tweet); } } catch (error) { - logger.error('Error processing tweet:', error); + logger.error("Error processing tweet:", error); } } @@ -191,7 +206,7 @@ export class TwitterService { } } } catch (error) { - logger.error('Error checking mentions:', error); + logger.error("Error checking mentions:", error); } }, 60000); // Check every minute } @@ -209,43 +224,74 @@ 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." + // 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`, ); - logger.info(`User ${userId} has reached limit, replied to submission.`); 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: originalTweet.userId!, + username: originalTweet.username!, + content: originalTweet.text || "", + hashtags: originalTweet.hashtags || [], + status: "pending", + moderationHistory: [], + createdAt: + originalTweet.timeParsed?.toISOString() || new Date().toISOString(), + }; + + // 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); } } @@ -255,7 +301,7 @@ export class TwitterService { // Verify admin status using cached ID if (!this.isAdmin(userId)) { - logger.info(`User ${userId} is not admin.`) + logger.info(`User ${userId} is not admin.`); return; // Silently ignore non-admin moderation attempts } @@ -269,7 +315,9 @@ export class TwitterService { // Check if submission has already been moderated by any admin if (submission.moderationHistory.length > 0) { - logger.info(`Submission ${submission.tweetId} has already been moderated, ignoring new moderation attempt.`); + logger.info( + `Submission ${submission.tweetId} has already been moderated, ignoring new moderation attempt.`, + ); return; } @@ -287,10 +335,14 @@ export class TwitterService { // Process the moderation action if (action === "approve") { - logger.info(`Received review from Admin ${this.adminIdCache.get(userId)}, processing approval.`) + logger.info( + `Received review from Admin ${this.adminIdCache.get(userId)}, processing approval.`, + ); await this.processApproval(submission); } else { - logger.info(`Received review from Admin ${this.adminIdCache.get(userId)}, processing rejection.`) + logger.info( + `Received review from Admin ${this.adminIdCache.get(userId)}, processing rejection.`, + ); await this.processRejection(submission); } } @@ -299,25 +351,33 @@ export class TwitterService { // TODO: Add NEAR integration here for approved submissions const responseTweetId = await this.replyToTweet( submission.tweetId, - "Your submission has been approved and will be added to the public goods news feed!" + "Your submission has been approved and will be added to the public goods news feed!", ); if (responseTweetId) { - db.updateSubmissionStatus(submission.tweetId, "approved", responseTweetId); + db.updateSubmissionStatus( + submission.tweetId, + "approved", + responseTweetId, + ); } } private async processRejection(submission: TwitterSubmission): Promise { const responseTweetId = await this.replyToTweet( submission.tweetId, - "Your submission has been reviewed and was not accepted for the public goods news feed." + "Your submission has been reviewed and was not accepted for the public goods news feed.", ); if (responseTweetId) { - db.updateSubmissionStatus(submission.tweetId, "rejected", responseTweetId); + db.updateSubmissionStatus( + submission.tweetId, + "rejected", + responseTweetId, + ); } } private getModerationAction(tweet: Tweet): "approve" | "reject" | null { - const hashtags = tweet.hashtags.map(tag => tag.toLowerCase()); + const hashtags = tweet.hashtags?.map((tag) => tag.toLowerCase()) || []; if (hashtags.includes("approve")) return "approve"; if (hashtags.includes("reject")) return "reject"; return null; @@ -331,20 +391,27 @@ export class TwitterService { return tweet.text?.toLowerCase().includes("!submit") || false; } - private async replyToTweet(tweetId: string, message: string): Promise { + private async replyToTweet( + tweetId: string, + message: string, + ): Promise { try { const response = await this.client.sendTweet(message, tweetId); - const responseData = await response.json() as any; + const responseData = (await response.json()) as any; // Extract tweet ID from response - const replyTweetId = responseData?.data?.create_tweet?.tweet_results?.result?.rest_id; + const replyTweetId = + responseData?.data?.create_tweet?.tweet_results?.result?.rest_id; return replyTweetId || null; } catch (error) { - logger.error('Error replying to tweet:', error); + logger.error("Error replying to tweet:", error); return null; } } - private getTweetLink(tweetId: string, username: string = this.twitterUsername): string { + private getTweetLink( + tweetId: string, + username: string = this.twitterUsername, + ): string { return `https://x.com/${username}/status/${tweetId}`; } } 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 72% rename from src/types/index.ts rename to backend/src/types/index.ts index 66dce56..38d59cc 100644 --- a/src/types/index.ts +++ b/backend/src/types/index.ts @@ -1,8 +1,6 @@ export * from "./twitter"; -export * from "./near"; export interface AppConfig { twitter: import("./twitter").TwitterConfig; - near: import("./near").NearConfig; environment: "development" | "production" | "test"; } diff --git a/src/types/twitter.ts b/backend/src/types/twitter.ts similarity index 85% rename from src/types/twitter.ts rename to backend/src/types/twitter.ts index ec93ded..d6735d7 100644 --- a/src/types/twitter.ts +++ b/backend/src/types/twitter.ts @@ -1,6 +1,7 @@ export interface TwitterSubmission { tweetId: string; userId: string; + username: string; content: string; hashtags: Array; category?: string; @@ -9,6 +10,7 @@ export interface TwitterSubmission { moderationHistory: Moderation[]; acknowledgmentTweetId?: string; moderationResponseTweetId?: string; + createdAt: string; } export interface Moderation { @@ -22,8 +24,4 @@ export interface TwitterConfig { username: string; password: string; email: string; - apiKey: string; - apiSecret: string; - accessToken: string; - accessTokenSecret: string; } diff --git a/src/utils/cache.ts b/backend/src/utils/cache.ts similarity index 61% rename from src/utils/cache.ts rename to backend/src/utils/cache.ts index 1cef1ee..380ba28 100644 --- a/src/utils/cache.ts +++ b/backend/src/utils/cache.ts @@ -16,31 +16,31 @@ interface CookieCache { [username: string]: TwitterCookie[]; } -const CACHE_DIR = '.cache'; +const CACHE_DIR = process.env.CACHE_DIR || ".cache"; export async function ensureCacheDirectory() { try { - const cacheDir = path.join(process.cwd(), CACHE_DIR); - try { - await fs.access(cacheDir); + await fs.access(CACHE_DIR); } catch { // Directory doesn't exist, create it - await fs.mkdir(cacheDir, { recursive: true }); - logger.info('Created cache directory'); + await fs.mkdir(CACHE_DIR, { recursive: true }); + logger.info("Created cache directory"); } } catch (error) { - logger.error('Failed to create cache directory:', error); + logger.error("Failed to create cache directory:", error); throw error; } } -export async function getCachedCookies(username: string): Promise { +export async function getCachedCookies( + username: string, +): Promise { try { // Try to read cookies from a local cache file - const cookiePath = path.join(process.cwd(), CACHE_DIR, '.twitter-cookies.json'); + const cookiePath = path.join(CACHE_DIR, ".twitter-cookies.json"); - const data = await fs.readFile(cookiePath, 'utf-8'); + const data = await fs.readFile(cookiePath, "utf-8"); const cache: CookieCache = JSON.parse(data); if (cache[username]) { @@ -55,11 +55,11 @@ export async function getCachedCookies(username: string): Promise { } spinners[key] = ora({ text: `${text}\n`, - color: 'cyan', - spinner: 'dots', + color: "cyan", + spinner: "dots", }).start(); }; @@ -95,8 +95,8 @@ export const cleanup = (): void => { }; // Register cleanup on process exit -process.on('exit', cleanup); -process.on('SIGINT', () => { +process.on("exit", cleanup); +process.on("SIGINT", () => { cleanup(); process.exit(0); }); diff --git a/tsconfig.json b/backend/tsconfig.json similarity index 75% rename from tsconfig.json rename to backend/tsconfig.json index 1d1efe1..2e6c2a8 100644 --- a/tsconfig.json +++ b/backend/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "es2020", - "module": "commonjs", - "lib": ["es2020"], + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -14,8 +14,9 @@ "moduleResolution": "node", "baseUrl": ".", "paths": { - "@/*": ["src/*"] - } + "*": ["src/*"] + }, + "types": ["bun-types"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] diff --git a/bun.lockb b/bun.lockb index 9694d22..c136ad8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..03f7cb3 --- /dev/null +++ b/fly.toml @@ -0,0 +1,31 @@ +# fly.toml app configuration file generated for public-goods-news-quiet-wildflower-8563 on 2024-12-18T14:47:13-06:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'public-goods-news-quiet-wildflower-8563' +primary_region = 'lax' + +[build] + dockerfile = 'Dockerfile' + +[env] + PORT = '3000' + +[[mounts]] + source = 'data' + destination = '/.data' + initial_size = '1GB' + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..6cedcb3 --- /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..ef274e2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,129 @@ + + + + + +
+ +

Public Goods News Frontend

+ +

+ React-based frontend application for the Public Goods News Curation platform +

+ +
+ +
+ Table of Contents + +- [Architecture Overview](#architecture-overview) + - [Tech Stack](#tech-stack) + - [Application Structure](#application-structure) +- [Key Features](#key-features) + - [Submission Interface](#submission-interface) + - [Real-time Updates](#real-time-updates) + - [Responsive Design](#responsive-design) +- [Development](#development) + - [Prerequisites](#prerequisites) + - [Local Setup](#local-setup) +- [Backend Integration](#backend-integration) + +
+ +## Architecture Overview + +### Tech Stack + +The frontend leverages modern web technologies for optimal performance and developer experience: + +- **Framework**: [React](https://reactjs.org) + TypeScript + - Component-based architecture + - Strong type safety + - Excellent ecosystem support + +- **Build Tool**: [Vite](https://vitejs.dev) + - Lightning-fast development server + - Optimized production builds + - Modern development experience + +- **Styling**: [Tailwind CSS](https://tailwindcss.com) + - Utility-first CSS framework + - Highly customizable + - Zero runtime overhead + +### Application Structure + +```bash +src/ +├── assets/ # Static assets +├── components/ # React components +├── types/ # TypeScript definitions +└── App.tsx # Root component +``` + +## Key Features + +### Submission Interface + +The submission system provides: + +- Intuitive submission form +- Real-time validation +- Status tracking +- Moderation feedback + +### Real-time Updates + +- Live submission status updates +- Dynamic content loading +- Optimistic UI updates + +### Responsive Design + +- Mobile-first approach +- Adaptive layouts +- Cross-browser compatibility + +## Development + +### Prerequisites + +- Node.js 18+ +- Bun (recommended) or npm +- Backend service running + +### Local Setup + +1. Install dependencies: + +```bash +bun install +``` + +2. Start development server: + +```bash +bun run dev +``` + +The app will be available at `http://localhost:5173` + +## Backend Integration + +The frontend communicates with the [backend service](../backend/README.md) through a RESTful API: + +- Submission handling via `/submit` endpoint +- Content retrieval through `/submissions` +- Real-time updates using polling (future: WebSocket support) + +See the [Backend README](../backend/README.md) for detailed API documentation. + +
+ +Near Builders + +
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..79a552e --- /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 @@ + + + + + + Curation Dashboard + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..aca8ed4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 5173", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/typography": "^0.5.15", + "autoprefixer": "^10.4.20", + "axios": "^1.7.9", + "postcss": "^8.4.49", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.0.2", + "tailwindcss": "^3.4.16" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "typescript": "~5.6.2", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..551c138 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,17 @@ +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import SubmissionList from "./components/SubmissionList"; +import { LiveUpdateProvider } from "./contexts/LiveUpdateContext"; + +function App() { + return ( + + + + } /> + + + + ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/SubmissionList.tsx b/frontend/src/components/SubmissionList.tsx new file mode 100644 index 0000000..25b936c --- /dev/null +++ b/frontend/src/components/SubmissionList.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import axios from "axios"; +import { TwitterSubmission } from "../types/twitter"; +import { useLiveUpdates } from "../contexts/LiveUpdateContext"; + +const StatusBadge = ({ status }: { status: TwitterSubmission["status"] }) => { + const className = `status-badge status-${status}`; + return {status}; +}; + +const SubmissionList = () => { + const [submissions, setSubmissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState( + "all", + ); + const { connected, lastUpdate } = useLiveUpdates(); + + const fetchSubmissions = async () => { + try { + setLoading(true); + const url = + filter === "all" + ? "/api/submissions" + : `/api/submissions?status=${filter}`; + const response = await axios.get(url); + setSubmissions(response.data); + setError(null); + } catch (err) { + setError("Failed to fetch submissions"); + console.error("Error fetching submissions:", err); + } finally { + setLoading(false); + } + }; + + // Initial fetch + useEffect(() => { + fetchSubmissions(); + }, [filter]); + + // Handle live updates + useEffect(() => { + if (lastUpdate?.type === "update") { + const updatedSubmissions = + filter === "all" + ? lastUpdate.data + : lastUpdate.data.filter((s) => s.status === filter); + setSubmissions(updatedSubmissions); + } + }, [lastUpdate, filter]); + + const getTweetUrl = (tweetId: string, username: string) => { + return `https://x.com/${username}/status/${tweetId}`; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+

Public Goods News Submissions

+
+
+
+ + {connected ? "Live Updates" : "Connecting..."} + +
+
+ {(["all", "pending", "approved", "rejected"] as const).map( + (status) => ( + + ), + )} +
+
+
+ +
+ {submissions.map((submission) => ( +
+
+
+
+ + @{submission.username} + + · + + {formatDate(submission.createdAt)} + +
+

{submission.content}

+
+ {submission.hashtags.map((tag) => ( + + #{tag} + + ))} +
+
+ +
+ + {submission.category && ( +

+ Category:{" "} + {submission.category} +

+ )} + + {submission.description && ( +

+ Description:{" "} + {submission.description} +

+ )} + + {submission.moderationHistory.length > 0 && ( +
+

Moderation History

+
+ {submission.moderationHistory.map((history, index) => ( +
+ {history.action} by{" "} + {history.adminId} on{" "} + {new Date(history.timestamp).toLocaleString()} +
+ ))} +
+
+ )} +
+ ))} + + {submissions.length === 0 && ( +
+ No submissions found +
+ )} +
+
+ ); +}; + +export default SubmissionList; diff --git a/frontend/src/contexts/LiveUpdateContext.tsx b/frontend/src/contexts/LiveUpdateContext.tsx new file mode 100644 index 0000000..0c546ce --- /dev/null +++ b/frontend/src/contexts/LiveUpdateContext.tsx @@ -0,0 +1,91 @@ +import { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; +import { TwitterSubmission } from "../types/twitter"; + +type LiveUpdateContextType = { + connected: boolean; + lastUpdate: { type: string; data: TwitterSubmission[] } | null; +}; + +const LiveUpdateContext = createContext( + undefined, +); + +export function LiveUpdateProvider({ children }: { children: ReactNode }) { + const [connected, setConnected] = useState(false); + const [lastUpdate, setLastUpdate] = useState<{ + type: string; + data: TwitterSubmission[]; + } | null>(null); + + useEffect(() => { + let ws: WebSocket; + let reconnectTimer: number; + + const connect = () => { + // Use the proxied WebSocket path + // In development, Vite runs on a different port than the backend + const isDev = import.meta.env.DEV; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = isDev + ? `ws://localhost:3000/ws` // Direct connection to backend in development + : `${protocol}//${window.location.host}/ws`; // Use relative path in production + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log("Live updates connected"); + setConnected(true); + }; + + ws.onclose = () => { + console.log("Live updates disconnected"); + setConnected(false); + // Try to reconnect after 3 seconds + reconnectTimer = window.setTimeout(connect, 3000); + }; + + ws.onerror = (error) => { + console.error("Live updates error:", error); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === "update") { + setLastUpdate(data); + } + } catch (error) { + console.error("Error processing update:", error); + } + }; + }; + + connect(); + + // Cleanup function + return () => { + window.clearTimeout(reconnectTimer); + ws?.close(); + }; + }, []); // Empty dependency array - only run once on mount + + return ( + + {children} + + ); +} + +export function useLiveUpdates() { + const context = useContext(LiveUpdateContext); + if (context === undefined) { + throw new Error("useLiveUpdates must be used within a LiveUpdateProvider"); + } + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a6fa24f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .status-badge { + @apply px-2 py-1 rounded-full text-sm font-semibold; + } + + .status-pending { + @apply bg-yellow-100 text-yellow-800; + } + + .status-approved { + @apply bg-green-100 text-green-800; + } + + .status-rejected { + @apply bg-red-100 text-red-800; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..27481e0 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/types/twitter.ts b/frontend/src/types/twitter.ts new file mode 100644 index 0000000..c22be76 --- /dev/null +++ b/frontend/src/types/twitter.ts @@ -0,0 +1 @@ +export * from "../../../backend/src/types/twitter"; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..e78605b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: {}, + }, + plugins: [require("@tailwindcss/typography")], +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..358ca9b --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e9556cf --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:3000", + "/ws": { + target: "ws://localhost:3000", + ws: true, + }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/package.json b/package.json index e77295b..e158988 100644 --- a/package.json +++ b/package.json @@ -1,45 +1,25 @@ { "name": "public-goods-news", - "version": "0.0.1", - "homepage": "/", + "private": true, + "type": "module", + "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", + "dev": "bunx turbo run dev", + "build": "bunx turbo run build", + "start": "NODE_ENV=production bun run backend/dist/index.js", + "lint": "bunx turbo run lint", + "deploy:init": "fly launch", + "deploy:volumes": "fly volumes create sqlite --size 1 --region lax && fly volumes create cache --size 1 --region lax", + "deploy": "fly deploy", "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" - ] - }, + "workspaces": [ + "frontend", + "backend" + ], "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", - "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" + "turbo": "latest", + "prettier": "^3.3.3" } } diff --git a/src/config/config.ts b/src/config/config.ts deleted file mode 100644 index 052802b..0000000 --- a/src/config/config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { AppConfig } from "../types"; - -const config: AppConfig = { - twitter: { - username: process.env.TWITTER_USERNAME!, - password: process.env.TWITTER_PASSWORD!, - email: process.env.TWITTER_EMAIL!, - apiKey: process.env.TWITTER_API_KEY || "", - apiSecret: process.env.TWITTER_API_SECRET || "", - accessToken: process.env.TWITTER_ACCESS_TOKEN || "", - accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET || "", - }, - near: { - networkId: process.env.NEAR_NETWORK_ID || "testnet", - nodeUrl: process.env.NEAR_NODE_URL || "https://rpc.testnet.near.org", - walletUrl: process.env.NEAR_WALLET_URL || "https://wallet.testnet.near.org", - contractName: process.env.NEAR_CONTRACT_NAME || "dev-1234567890-1234567890", - }, - environment: - (process.env.NODE_ENV as "development" | "production" | "test") || - "development", -}; - -export default config; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index a0bd62c..0000000 --- a/src/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import dotenv from "dotenv"; -import { TwitterService } from "./services/twitter/client"; -import { NearService } from "./services/near"; -import config from "./config/config"; -import { - logger, - startSpinner, - succeedSpinner, - failSpinner, - cleanup -} from "./utils/logger"; - -async function main() { - try { - // Load environment variables - startSpinner('env', 'Loading environment variables...'); - dotenv.config(); - succeedSpinner('env', 'Environment variables loaded'); - - // Initialize NEAR service - startSpinner('near', 'Initializing NEAR service...'); - const nearService = new NearService(config.near); - succeedSpinner('near', 'NEAR service initialized'); - - // Initialize Twitter service - startSpinner('twitter-init', 'Initializing Twitter service...'); - const twitterService = new TwitterService(config.twitter); - await twitterService.initialize(); - succeedSpinner('twitter-init', 'Twitter service initialized'); - - // Handle graceful shutdown - process.on("SIGINT", async () => { - startSpinner('shutdown', 'Shutting down gracefully...'); - try { - await twitterService.stop(); - succeedSpinner('shutdown', 'Shutdown complete'); - process.exit(0); - } catch (error) { - failSpinner('shutdown', 'Error during shutdown'); - logger.error('Shutdown', error); - process.exit(1); - } - }); - - logger.info('🚀 Bot is running and ready for events', { - nearNetwork: config.near.networkId, - twitterEnabled: true - }); - - // Start checking for mentions - startSpinner('twitter-mentions', 'Starting mentions check...'); - await twitterService.startMentionsCheck(); - succeedSpinner('twitter-mentions', 'Mentions check started'); - - } catch (error) { - // Handle any initialization errors - ['env', 'near', 'twitter-init', 'twitter-mentions'].forEach(key => { - failSpinner(key, `Failed during ${key}`); - }); - logger.error('Startup', error); - cleanup(); - process.exit(1); - } -} - -// Start the application -logger.info('Starting Public Goods News Bot...'); -main().catch((error) => { - logger.error('Unhandled Exception', error); - process.exit(1); -}); diff --git a/src/services/near/index.ts b/src/services/near/index.ts deleted file mode 100644 index 28e8a01..0000000 --- a/src/services/near/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { connect, keyStores, Near } from "near-api-js"; -import type { NearConfig } from "../../types"; - -export class NearService { - private near: Near; - - constructor(config: NearConfig) { - this.near = new Near({ - ...config, - keyStore: new keyStores.InMemoryKeyStore(), - headers: {}, - }); - } - - async getAccount(accountId: string) { - try { - const account = await this.near.account(accountId); - return account; - } catch (error) { - console.error("Error getting NEAR account:", error); - throw error; - } - } - - async viewMethod(contractId: string, method: string, args: object = {}) { - try { - const account = await this.near.account(contractId); - const result = await account.viewFunction({ - contractId, - methodName: method, - args, - }); - return result; - } catch (error) { - console.error("Error calling view method:", error); - throw error; - } - } - - async callMethod( - contractId: string, - method: string, - args: object = {}, - deposit: string = "0", - ) { - try { - const account = await this.near.account(contractId); - const result = await account.functionCall({ - contractId, - methodName: method, - args, - attachedDeposit: deposit, - }); - return result; - } catch (error) { - console.error("Error calling method:", error); - throw error; - } - } -} diff --git a/src/types/near.ts b/src/types/near.ts deleted file mode 100644 index e8d05fc..0000000 --- a/src/types/near.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface NewsProposal { - id: string; - submitter: string; - content: string; - category: string; - status: "pending" | "approved" | "rejected"; - tweetId: string; -} - -export interface NearConfig { - networkId: string; - nodeUrl: string; - walletUrl: string; - contractName: string; -} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..e818cf5 --- /dev/null +++ b/turbo.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["**/.env"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "dev": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "lint": { + "outputs": [] + } + } +}