production: reminder cron, dashboard overhaul, shadcn components, setup wizard

- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback
- /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate)
- /api/pledges: GET handler with filtering, search, pagination, sort by dueDate
- Dashboard overview: stats, collection progress bar, needs attention, upcoming payments
- Dashboard pledges: proper table with status tabs, search, actions, pagination
- New shadcn components: Table, Tabs, DropdownMenu, Progress
- Setup wizard: 4-step onboarding (org → bank → event → QR code)
- Settings API: PUT handler for org create/update
- Org resolver: single-tenant fallback to first org
- Cron jobs installed: reminders every 15min, overdue check at 6am
- Auto-generates installment dates when not provided
- HOSTNAME=0.0.0.0 in compose for multi-network binding
This commit is contained in:
2026-03-03 05:11:17 +08:00
parent 250221b530
commit c79b9bcabc
61 changed files with 3547 additions and 534 deletions

142
lib/telegram.ts Normal file
View File

@@ -0,0 +1,142 @@
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const DEFAULT_CHAT_ID = process.env.TELEGRAM_CHAT_ID!;
const ALLOWED_IDS = new Set(
(process.env.TELEGRAM_ALLOWED_CHAT_IDS || DEFAULT_CHAT_ID)
.split(",")
.map((id) => id.trim())
);
const api = async (method: string, body?: Record<string, unknown>) => {
const res = await fetch(
`https://api.telegram.org/bot${BOT_TOKEN}/${method}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
}
);
return res.json();
};
export function isAllowed(chatId: number | string): boolean {
return ALLOWED_IDS.has(String(chatId));
}
export async function send(
text: string,
chatId: string | number = DEFAULT_CHAT_ID,
opts?: { reply_to?: number; keyboard?: InlineKeyboard }
): Promise<any> {
if (!isAllowed(chatId)) return null;
// Telegram limits messages to 4096 chars
const truncated =
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
const body: Record<string, unknown> = {
chat_id: chatId,
text: truncated,
parse_mode: "Markdown",
};
if (opts?.reply_to) body.reply_to_message_id = opts.reply_to;
if (opts?.keyboard) {
body.reply_markup = { inline_keyboard: opts.keyboard };
}
return api("sendMessage", body);
}
export async function editMessage(
chatId: string | number,
messageId: number,
text: string,
keyboard?: InlineKeyboard
): Promise<any> {
const truncated =
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
text: truncated,
parse_mode: "Markdown",
};
if (keyboard) body.reply_markup = { inline_keyboard: keyboard };
return api("editMessageText", body);
}
export async function answerCallback(
callbackId: string,
text?: string
): Promise<any> {
return api("answerCallbackQuery", {
callback_query_id: callbackId,
text,
});
}
export type InlineKeyboard = Array<
Array<{ text: string; callback_data: string }>
>;
export function agentKeyboard(agentId: number): InlineKeyboard {
return [
[
{ text: "📋 Logs", callback_data: `logs_${agentId}` },
{ text: "💬 Talk", callback_data: `talk_${agentId}` },
{ text: "🛑 Kill", callback_data: `kill_${agentId}` },
],
];
}
let offset = 0;
export interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from?: { id: number; first_name: string };
chat: { id: number; type: string };
text?: string;
reply_to_message?: { message_id: number };
};
callback_query?: {
id: string;
from: { id: number };
message?: { message_id: number; chat: { id: number } };
data?: string;
};
}
export async function poll(): Promise<TelegramUpdate[]> {
try {
const data = await api("getUpdates", {
offset,
timeout: 30,
allowed_updates: ["message", "callback_query"],
});
const updates: TelegramUpdate[] = data.result || [];
if (updates.length > 0) {
offset = updates[updates.length - 1].update_id + 1;
}
return updates;
} catch (e) {
console.error("[telegram] poll error:", e);
return [];
}
}
export async function deleteMessage(
chatId: string | number,
messageId: number
): Promise<boolean> {
try {
const res = await api("deleteMessage", {
chat_id: chatId,
message_id: messageId,
});
return !!res?.ok;
} catch {
return false;
}
}
export default { send, editMessage, deleteMessage, poll, isAllowed, agentKeyboard, answerCallback };