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.
+
+
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.
+
+
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 (
+
+ );
+ }
+
+ return (
+
+
+
Public Goods News Submissions
+
+
+
+
+ {connected ? "Live Updates" : "Connecting..."}
+
+
+
+ {(["all", "pending", "approved", "rejected"] as const).map(
+ (status) => (
+
+ ),
+ )}
+
+
+
+
+
+ {submissions.map((submission) => (
+
+
+
+
+
{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": []
+ }
+ }
+}