diff --git a/package.json b/package.json
index 1d46bfb..6698c10 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pgt-web-app",
- "version": "0.1.20",
+ "version": "0.1.22",
"private": true,
"type": "module",
"scripts": {
@@ -29,13 +29,14 @@
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
+ "@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
- "@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"bree": "^9.2.4",
diff --git a/src/app/admin/worker-heartbeats/page.tsx b/src/app/admin/worker-heartbeats/page.tsx
new file mode 100644
index 0000000..467d071
--- /dev/null
+++ b/src/app/admin/worker-heartbeats/page.tsx
@@ -0,0 +1,23 @@
+import { WorkerHeartbeatsTable } from "@/components/admin/worker-heartbeats/WorkerHeartbeatsTable";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Worker Heartbeats | MEF Admin",
+ description: "Monitor background job statuses and heartbeats",
+};
+
+export default function WorkerHeartbeatsPage() {
+ return (
+
+
+
+ Worker Heartbeats
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/api/admin/worker-heartbeats/route.ts b/src/app/api/admin/worker-heartbeats/route.ts
new file mode 100644
index 0000000..83dc7e5
--- /dev/null
+++ b/src/app/api/admin/worker-heartbeats/route.ts
@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+import { getOrCreateUserFromRequest } from "@/lib/auth";
+import { AdminService } from "@/services/AdminService";
+import { ApiResponse } from "@/lib/api-response";
+import { AppError } from "@/lib/errors";
+import { AuthErrors } from "@/constants/errors";
+import logger from "@/logging";
+import { WorkerStatus } from "@prisma/client";
+
+const adminService = new AdminService(prisma);
+const DEFAULT_PAGE_SIZE = 25;
+
+type SortField = 'createdAt' | 'lastHeartbeat' | 'status' | 'name';
+type SortOrder = 'asc' | 'desc';
+
+export async function GET(request: Request) {
+ try {
+ const user = await getOrCreateUserFromRequest(request);
+ if (!user) {
+ throw AppError.unauthorized(AuthErrors.UNAUTHORIZED);
+ }
+
+ const isAdmin = await adminService.checkAdminStatus(user.id, user.linkId);
+ if (!isAdmin) {
+ throw AppError.forbidden(AuthErrors.FORBIDDEN);
+ }
+
+ // Parse query parameters
+ const { searchParams } = new URL(request.url);
+ const page = Math.max(1, Number(searchParams.get('page')) || 1);
+ const pageSize = Math.max(1, Number(searchParams.get('pageSize')) || DEFAULT_PAGE_SIZE);
+ const sortField = (searchParams.get('sortField') as SortField) || 'createdAt';
+ const sortOrder = (searchParams.get('sortOrder') as SortOrder) || 'desc';
+
+ // Validate sort field
+ const validSortFields: SortField[] = ['createdAt', 'lastHeartbeat', 'status', 'name'];
+ if (!validSortFields.includes(sortField)) {
+ throw AppError.badRequest('Invalid sort field');
+ }
+
+ // Get total count for pagination
+ const totalCount = await prisma.workerHeartbeat.count();
+ const totalPages = Math.ceil(totalCount / pageSize);
+
+ // Fetch paginated and sorted data
+ const heartbeats = await prisma.workerHeartbeat.findMany({
+ take: pageSize,
+ skip: (page - 1) * pageSize,
+ orderBy: {
+ [sortField]: sortOrder,
+ },
+ });
+
+ logger.info(`Fetched ${heartbeats.length} worker heartbeats (page ${page}/${totalPages})`);
+
+ // Return paginated response
+ return ApiResponse.success({
+ data: heartbeats,
+ pagination: {
+ currentPage: page,
+ totalPages,
+ pageSize,
+ totalCount,
+ },
+ sort: {
+ field: sortField,
+ order: sortOrder,
+ },
+ });
+ } catch (error) {
+ return ApiResponse.error(error);
+ }
+}
\ No newline at end of file
diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx
index 51247dc..7fb7d4d 100644
--- a/src/components/AdminDashboard.tsx
+++ b/src/components/AdminDashboard.tsx
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Users, MessageSquare, Coins, FileCheck, Vote } from 'lucide-react'
+import { Users, MessageSquare, Coins, FileCheck, Vote, Activity } from 'lucide-react'
import Link from "next/link"
export function AdminDashboardComponent() {
@@ -36,6 +36,12 @@ export function AdminDashboardComponent() {
description: "Count Votes for a Funding Round..",
icon: ,
href: "/admin/votes"
+ },
+ {
+ title: "Worker Heartbeats",
+ description: "Monitor background job statuses",
+ icon: ,
+ href: "/admin/worker-heartbeats"
}
]
diff --git a/src/components/admin/worker-heartbeats/WorkerHeartbeatsTable.tsx b/src/components/admin/worker-heartbeats/WorkerHeartbeatsTable.tsx
new file mode 100644
index 0000000..6f74f84
--- /dev/null
+++ b/src/components/admin/worker-heartbeats/WorkerHeartbeatsTable.tsx
@@ -0,0 +1,315 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { CopyIcon, InfoIcon, ArrowUpDown, AlertCircle } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { WorkerStatus } from "@prisma/client";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+import { cn } from "@/lib/utils";
+
+// Match the Prisma schema exactly
+interface WorkerHeartbeat {
+ id: string;
+ name: string;
+ lastHeartbeat: string; // ISO date string
+ status: WorkerStatus;
+ metadata: Record | null;
+ createdAt: string; // ISO date string
+}
+
+const statusColors = {
+ RUNNING: "bg-blue-500",
+ COMPLETED: "bg-green-500",
+ FAILED: "bg-red-500",
+ NOT_STARTED: "bg-gray-500",
+} as const;
+
+type SortField = keyof Pick;
+type SortOrder = 'asc' | 'desc';
+
+interface SortConfig {
+ field: SortField;
+ order: SortOrder;
+}
+
+interface PaginatedResponse {
+ data: WorkerHeartbeat[];
+ pagination: {
+ currentPage: number;
+ totalPages: number;
+ pageSize: number;
+ totalCount: number;
+ };
+ sort: {
+ field: SortField;
+ order: SortOrder;
+ };
+}
+
+export const WorkerHeartbeatsTable: React.FC = () => {
+ const [heartbeats, setHeartbeats] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [sortConfig, setSortConfig] = useState({
+ field: 'createdAt',
+ order: 'desc',
+ });
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+ };
+
+ useEffect(() => {
+ const fetchHeartbeats = async () => {
+ try {
+ setIsLoading(true);
+ const params = new URLSearchParams({
+ page: currentPage.toString(),
+ pageSize: '25',
+ sortField: sortConfig.field,
+ sortOrder: sortConfig.order,
+ });
+
+ const response = await fetch(`/api/admin/worker-heartbeats?${params}`);
+ const json = await response.json();
+
+ if (!response.ok) {
+ throw new Error(json.message || 'Failed to fetch heartbeats');
+ }
+
+ const { data, pagination } = json as PaginatedResponse;
+ setHeartbeats(data);
+ setTotalPages(pagination.totalPages);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchHeartbeats();
+ }, [currentPage, sortConfig]);
+
+ const handleSort = (field: SortField) => {
+ setSortConfig(current => ({
+ field,
+ order: current.field === field && current.order === 'asc' ? 'desc' : 'asc'
+ }));
+ };
+
+ const getSortIcon = (field: SortField) => {
+ if (sortConfig.field !== field) {
+ return ;
+ }
+ return (
+
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (heartbeats.length === 0) {
+ return (
+
+ No worker heartbeats found
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ handleSort('name')}
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ >
+
+ Job Name {getSortIcon('name')}
+
+
+ handleSort('status')}
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ >
+
+ Status {getSortIcon('status')}
+
+
+ handleSort('lastHeartbeat')}
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ >
+
+ Last Heartbeat {getSortIcon('lastHeartbeat')}
+
+
+ handleSort('createdAt')}
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ >
+
+ Created At {getSortIcon('createdAt')}
+
+
+ Metadata
+
+
+
+ {heartbeats.map((heartbeat) => (
+
+ {heartbeat.name}
+
+
+ {heartbeat.status}
+
+
+
+ {formatDistanceToNow(new Date(heartbeat.lastHeartbeat), {
+ addSuffix: true,
+ })}
+
+
+ {formatDistanceToNow(new Date(heartbeat.createdAt), {
+ addSuffix: true,
+ })}
+
+
+ {heartbeat.metadata ? (
+
+
+
+
+
+
+
+ {JSON.stringify(heartbeat.metadata, null, 2)}
+
+
+
+
+
+ ) : (
+ No metadata
+ )}
+
+
+ ))}
+
+
+
+
+ {totalPages > 1 && (
+
+
+
+
+ setCurrentPage(p => Math.max(1, p - 1))}
+ className={cn(
+ "cursor-pointer",
+ currentPage === 1 && "pointer-events-none opacity-50"
+ )}
+ />
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
+
+ setCurrentPage(page)}
+ isActive={currentPage === page}
+ className="cursor-pointer"
+ >
+ {page}
+
+
+ ))}
+
+ setCurrentPage(p => Math.min(totalPages, p + 1))}
+ className={cn(
+ "cursor-pointer",
+ currentPage === totalPages && "pointer-events-none opacity-50"
+ )}
+ />
+
+
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx
new file mode 100644
index 0000000..e54d91c
--- /dev/null
+++ b/src/components/ui/hover-card.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
+
+import { cn } from "@/lib/utils"
+
+const HoverCard = HoverCardPrimitive.Root
+
+const HoverCardTrigger = HoverCardPrimitive.Trigger
+
+const HoverCardContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+))
+HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..484e5ce
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,116 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { ButtonProps, buttonVariants } from "@/components/ui/button"
+import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+)
+Pagination.displayName = "Pagination"
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationContent.displayName = "PaginationContent"
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationItem.displayName = "PaginationItem"
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick &
+ React.ComponentProps<"a">
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+)
+PaginationLink.displayName = "PaginationLink"
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+)
+PaginationPrevious.displayName = "PaginationPrevious"
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+)
+PaginationNext.displayName = "PaginationNext"
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+)
+PaginationEllipsis.displayName = "PaginationEllipsis"
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/src/scripts/manage-forum-post.ats b/src/scripts/manage-forum-post.ats
new file mode 100644
index 0000000..91e5057
--- /dev/null
+++ b/src/scripts/manage-forum-post.ats
@@ -0,0 +1,303 @@
+import logger from '@/logging';
+import dotenv from "dotenv";
+import path from "path";
+import readline from 'readline';
+
+// Try to load .env file
+try {
+ const envPath = path.resolve(process.cwd(), ".env");
+ dotenv.config({ path: envPath });
+} catch (error) {
+ logger.warn("\x1b[33mNo .env file found, using environment variables\x1b[0m");
+}
+
+// Validate required env vars
+const DISCOURSE_API_KEY = process.env.DISCOURSE_API_KEY;
+const DISCOURSE_BASE_URL = 'https://forums.minaprotocol.com';
+
+if (!DISCOURSE_API_KEY) {
+ logger.error("Missing required environment variable: DISCOURSE_API_KEY");
+ process.exit(1);
+}
+
+// ANSI color codes from toggle-admin-status.ts
+const colors = {
+ reset: "\x1b[0m",
+ bright: "\x1b[1m",
+ dim: "\x1b[2m",
+ red: "\x1b[31m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ blue: "\x1b[34m",
+ cyan: "\x1b[36m",
+ gray: "\x1b[90m",
+};
+
+// Create readline interface
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout
+});
+
+// Promisify readline question
+const question = (query: string): Promise => {
+ return new Promise((resolve) => {
+ rl.question(query, resolve);
+ });
+};
+
+// API client for Discourse
+class DiscourseClient {
+ private headers: HeadersInit;
+
+ constructor() {
+ this.headers = {
+ 'Api-Key': DISCOURSE_API_KEY!,
+ 'Api-Username': 'system', // 'system' for API-only operations, as per docs
+ 'Accept': 'application/json'
+ };
+ }
+
+ async createTopic(categoryId: number, title: string, raw: string): Promise {
+ const response = await fetch(`${DISCOURSE_BASE_URL}/posts.json`, {
+ method: 'POST',
+ headers: this.headers,
+ body: JSON.stringify({
+ title,
+ raw,
+ category: categoryId
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(`Failed to create topic: ${JSON.stringify(error)}`);
+ }
+
+ return response.json();
+ }
+
+ async createPost(topicId: number, raw: string): Promise {
+ const response = await fetch(`${DISCOURSE_BASE_URL}/posts.json`, {
+ method: 'POST',
+ headers: this.headers,
+ body: JSON.stringify({
+ topic_id: topicId,
+ raw
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(`Failed to create post: ${JSON.stringify(error)}`);
+ }
+
+ return response.json();
+ }
+
+ async updatePost(postId: number, raw: string): Promise {
+ const response = await fetch(`${DISCOURSE_BASE_URL}/posts/${postId}.json`, {
+ method: 'PUT',
+ headers: this.headers,
+ body: JSON.stringify({
+ post: { raw }
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(`Failed to update post: ${JSON.stringify(error)}`);
+ }
+
+ return response.json();
+ }
+
+ async getCategories(): Promise {
+ const response = await fetch(`${DISCOURSE_BASE_URL}/categories.json?include_subcategories=true`, {
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ console.log(response);
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(`Failed to fetch categories: ${JSON.stringify(error)}`);
+ }
+
+ return response.json();
+ }
+
+ async testConnection(): Promise {
+ console.log('\nš Testing API connection and permissions...');
+
+ try {
+ // Test 1: Basic connection and authentication
+ console.log('\n1ļøā£ Testing basic connection and authentication...');
+ const response = await fetch(`${DISCOURSE_BASE_URL}/latest.json`, {
+ headers: this.headers
+ });
+
+ if (!response.ok) {
+ throw new Error(`Connection failed: ${response.status} ${response.statusText}`);
+ }
+ console.log(`${colors.green}ā${colors.reset} Basic connection successful`);
+
+ // Test 2: Categories access (requires read permissions)
+ console.log('\n2ļøā£ Testing categories access...');
+ const categoriesResponse = await fetch(`${DISCOURSE_BASE_URL}/categories.json`, {
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!categoriesResponse.ok) {
+ throw new Error(`Categories access failed: ${categoriesResponse.status} ${categoriesResponse.statusText}`);
+ }
+ console.log(`${colors.green}ā${colors.reset} Categories access successful`);
+
+ // Test 3: Post creation permissions (dry run)
+ console.log('\n3ļøā£ Testing post creation permissions...');
+ const createResponse = await fetch(`${DISCOURSE_BASE_URL}/posts.json`, {
+ method: 'POST',
+ headers: this.headers,
+ body: JSON.stringify({
+ title: 'API Test',
+ raw: 'API test post - please ignore',
+ category: 1
+ })
+ });
+
+ if (createResponse.status === 403) {
+ console.log(`${colors.red}ā${colors.reset} Post creation permission denied`);
+ } else if (createResponse.ok) {
+ console.log(`${colors.green}ā${colors.reset} Post creation permission granted`);
+ // If a test post was created, we should delete it
+ const result = await createResponse.json();
+ console.log(result);
+ if (result.id) {
+ await this.deletePost(result.id);
+ }
+ }
+
+ console.log('\nš API Configuration:');
+ console.log('āāāāāāāāāāāāāāāāāā');
+ console.log(`š Base URL: ${colors.cyan}${DISCOURSE_BASE_URL}${colors.reset}`);
+ console.log(`š API Key: ${colors.cyan}${DISCOURSE_API_KEY?.substring(0, 8)}...${colors.reset}`);
+ console.log(`š¤ Username: ${colors.cyan}system${colors.reset}`);
+ console.log('āāāāāāāāāāāāāāāāāā');
+
+ } catch (error) {
+ console.error(`\n${colors.red}Error testing API:${colors.reset}`, error instanceof Error ? error.message : 'Unknown error');
+ throw error;
+ }
+ }
+
+ async deletePost(postId: number): Promise {
+ const response = await fetch(`${DISCOURSE_BASE_URL}/posts/${postId}.json`, {
+ method: 'DELETE',
+ headers: this.headers
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to delete post: ${response.statusText}`);
+ }
+ }
+}
+
+async function promptForAction(): Promise<'test-api' | 'create-topic' | 'create-post' | 'edit-post' | 'exit'> {
+ console.log('\nš Available actions:');
+ console.log(`${colors.bright}1${colors.reset}) Test API connection`);
+ console.log(`${colors.bright}2${colors.reset}) Create new topic`);
+ console.log(`${colors.bright}3${colors.reset}) Create new post in topic`);
+ console.log(`${colors.bright}4${colors.reset}) Edit existing post`);
+ console.log(`${colors.bright}5${colors.reset}) Exit`);
+
+ const answer = await question('\nChoose an action (1-5): ');
+
+ switch (answer.trim()) {
+ case '1': return 'test-api';
+ case '2': return 'create-topic';
+ case '3': return 'create-post';
+ case '4': return 'edit-post';
+ case '5': return 'exit';
+ default: return promptForAction();
+ }
+}
+
+async function displayCategories(client: DiscourseClient) {
+ const { category_list } = await client.getCategories();
+
+ console.log('\nš Available categories:');
+ console.log('āāāāāāāāāāāāāāāāāāāāāāā');
+ category_list.categories.forEach((category: any) => {
+ console.log(`${colors.cyan}${category.id}${colors.reset}: ${category.name}`);
+ });
+ console.log('āāāāāāāāāāāāāāāāāāāāāāā');
+}
+
+async function main() {
+ const client = new DiscourseClient();
+
+ try {
+ while (true) {
+ const action = await promptForAction();
+
+ if (action === 'exit') {
+ console.log(`\n${colors.gray}Goodbye! š${colors.reset}`);
+ break;
+ }
+
+ switch (action) {
+ case 'test-api': {
+ await client.testConnection();
+ break;
+ }
+ case 'create-topic': {
+ await displayCategories(client);
+ const categoryId = parseInt(await question(`${colors.yellow}?${colors.reset} Enter category ID: `));
+ const title = await question(`${colors.yellow}?${colors.reset} Enter topic title: `);
+ const content = await question(`${colors.yellow}?${colors.reset} Enter topic content: `);
+
+ const result = await client.createTopic(categoryId, title, content);
+ console.log(`\n${colors.green}ā${colors.reset} Topic created successfully!`);
+ console.log(`${colors.bright}Topic ID:${colors.reset} ${colors.cyan}${result.topic_id}${colors.reset}`);
+ console.log(`${colors.bright}Post ID:${colors.reset} ${colors.cyan}${result.id}${colors.reset}`);
+ console.log(`${colors.bright}URL:${colors.reset} ${colors.cyan}${DISCOURSE_BASE_URL}/t/${result.topic_slug}/${result.topic_id}${colors.reset}`);
+ break;
+ }
+
+ case 'create-post': {
+ const topicId = parseInt(await question(`${colors.yellow}?${colors.reset} Enter topic ID: `));
+ const content = await question(`${colors.yellow}?${colors.reset} Enter post content: `);
+
+ const result = await client.createPost(topicId, content);
+ console.log(`\n${colors.green}ā${colors.reset} Post created successfully!`);
+ console.log(`${colors.bright}Post ID:${colors.reset} ${colors.cyan}${result.id}${colors.reset}`);
+ break;
+ }
+
+ case 'edit-post': {
+ const postId = parseInt(await question(`${colors.yellow}?${colors.reset} Enter post ID to edit: `));
+ const content = await question(`${colors.yellow}?${colors.reset} Enter new content: `);
+
+ await client.updatePost(postId, content);
+ console.log(`\n${colors.green}ā${colors.reset} Post updated successfully!`);
+ break;
+ }
+ }
+ }
+ } catch (error) {
+ console.error(`${colors.red}Error:${colors.reset}`, error instanceof Error ? error.message : 'Unknown error occurred');
+ process.exit(1);
+ } finally {
+ rl.close();
+ }
+}
+
+// Handle errors
+main().catch((error) => {
+ console.error(`${colors.red}Fatal error:${colors.reset}`, error instanceof Error ? error.message : error);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/src/services/ProposalStatusMoveService.ts b/src/services/ProposalStatusMoveService.ts
index 1d55de5..67c0cfa 100644
--- a/src/services/ProposalStatusMoveService.ts
+++ b/src/services/ProposalStatusMoveService.ts
@@ -87,8 +87,7 @@ export class ProposalStatusMoveService {
}
// Only process proposals in CONSIDERATION or DELIBERATION status
- if (proposal.status !== ProposalStatus.CONSIDERATION &&
- proposal.status !== ProposalStatus.DELIBERATION) {
+ if (![ProposalStatus.CONSIDERATION.toString(), ProposalStatus.DELIBERATION.toString()].includes(proposal.status.toString())) {
return null;
}
@@ -96,6 +95,8 @@ export class ProposalStatusMoveService {
? await this.shouldMoveToDeliberation(proposal)
: await this.shouldMoveBackToConsideration(proposal);
+ logger.info(`Proposal ${proposalId} should move to ${shouldMove ? ProposalStatus.DELIBERATION : ProposalStatus.CONSIDERATION}. Proposal status: ${proposal.status}t statu`);
+
if (shouldMove) {
const newStatus = proposal.status === ProposalStatus.CONSIDERATION
? ProposalStatus.DELIBERATION
@@ -158,6 +159,8 @@ export class ProposalStatusMoveService {
const ocvData = proposal.OCVConsiderationVote?.voteData as OCVVoteResponse | undefined;
const ocvEligible = ocvData?.eligible ?? false;
+ logger.info(`Proposal ${proposal.id} should move to DELIBERATION. Approval count: ${approvalCount}, min approvals: ${this.config.considerationPhase.minReviewerApprovals}, OCV eligible: ${ocvEligible}`);
+
return isMinApprovals || ocvEligible;
}
@@ -166,6 +169,8 @@ export class ProposalStatusMoveService {
const isMinApprovals = approvalCount >= this.config.considerationPhase.minReviewerApprovals;
const ocvData = proposal.OCVConsiderationVote?.voteData as OCVVoteResponse | undefined;
const ocvEligible = ocvData?.eligible ?? false;
+
+ logger.info(`Proposal ${proposal.id} should move back to CONSIDERATION. Approval count: ${approvalCount}, min approvals: ${this.config.considerationPhase.minReviewerApprovals}, OCV eligible: ${ocvEligible}`);
return !(isMinApprovals || ocvEligible);
}
diff --git a/src/tasks/ocv-vote-counting.ts b/src/tasks/ocv-vote-counting.ts
index ac53d3e..473d15a 100644
--- a/src/tasks/ocv-vote-counting.ts
+++ b/src/tasks/ocv-vote-counting.ts
@@ -30,7 +30,6 @@ const LOCK_KEY = 'ocv_vote_counting_job';
const PROCESS_PROPOSALS_HEARTBEAT_INTERVAL = 5000;
-// Define job names as constants
const WORKER_JOBS = {
OCV_VOTE_COUNTING: 'ocv-vote-counter',
STALE_JOB_CLEANUP: 'ocv-worker-cleanup'