import type { DatabaseClient } from "./database"; import { createAppChat, getAppChat, hasAppCredentials, sanitizeChatid, updateAppChat, type WecomConfig, } from "./wecom-client"; type FetchLike = typeof fetch; export type ProjectRow = { id: string; name: string; brand: string; main_group_member_ids: string; owner_userid: string; main_chat_id: string; status: string; note: string; created_at: string; }; export type ProjectSummary = { id: string; name: string; brand: string; status: string; note: string; createdAt: string; mainGroupMemberIds: string[]; ownerUserid: string; mainChatId: string; taskCount: number; }; export type ProjectTaskRow = { id: string; name: string; brand: string; quantity: number; claimed_quantity: number; due_at: string; status: string; task_type: string; platform: string; content_format: string; share_token: string | null; owner_userid: string; task_chat_id: string; recovery_enabled: number; recovery_due_at: string; dist_total: number; recovery_submitted: number; }; export type ProjectMainChat = { chatId: string; members: string[]; }; export type ProjectDetail = { project: ProjectSummary; tasks: ProjectTaskRow[]; mainChat: ProjectMainChat; }; export type MainChatEnsureReason = | "no_project" | "no_credentials" | "not_enough_members"; export type MainChatEnsureResult = { chatId: string | null; members: string[]; reason?: MainChatEnsureReason; warning?: string; }; export type GroupOwnerPartnerRow = { id: string; name: string; wecom_userid: string; partner_type: string; }; function text(value: unknown) { return String(value ?? "").trim(); } export class ProjectError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.status = status; } } export function parseMemberIds(value: unknown): string[] { const raw = Array.isArray(value) ? value.map((item) => text(item)) : text(value).split(/[\s,;,;]+/); const seen = new Set(); for (const item of raw) { if (item) seen.add(item); } return [...seen]; } function parseJsonArray(value: string): string[] { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []; } catch { return []; } } export function effectiveProjectMembers( memberIdsValue: unknown, ownerUserid: unknown, ): string[] { const raw = typeof memberIdsValue === "string" && memberIdsValue.trim().startsWith("[") ? parseJsonArray(memberIdsValue) : memberIdsValue; const members = parseMemberIds(raw); const owner = text(ownerUserid); if (owner && !members.includes(owner)) members.push(owner); return members; } export function projectMainChatId(projectId: string) { return `pgc${sanitizeChatid(text(projectId))}`; } export async function ensureProjectMainChat( db: DatabaseClient, projectId: string, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { const id = text(projectId); if (!id) return { chatId: null, members: [], reason: "no_project" }; const project = await db .prepare( "SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects WHERE id = ?", ) .bind(id) .first(); if (!project) return { chatId: null, members: [], reason: "no_project" }; const members = effectiveProjectMembers( project.main_group_member_ids, project.owner_userid, ); const existingChatId = text(project.main_chat_id); if (!hasAppCredentials(config)) { return { chatId: existingChatId || null, members, reason: existingChatId ? undefined : "no_credentials", }; } if (existingChatId) { try { const info = await getAppChat(config, existingChatId, fetchImpl); if (info) { const inGroup = new Set(info.userlist); const addUserList = members.filter((userid) => !inGroup.has(userid)); const delUserList = info.userlist.filter( (userid) => !members.includes(userid), ); if (addUserList.length > 0 || delUserList.length > 0) { await updateAppChat( config, { chatid: existingChatId, addUserList, delUserList }, fetchImpl, ); } } else { // appchat/get 要求应用可见范围为根部门,查不到成员时降级为只增同步(重复添加按成功处理) await updateAppChat( config, { chatid: existingChatId, addUserList: members }, fetchImpl, ); } return { chatId: existingChatId, members }; } catch (error) { return { chatId: existingChatId, members, warning: `项目总群成员同步失败:${error instanceof Error ? error.message : String(error)}`, }; } } if (members.length < 3) { return { chatId: null, members, reason: "not_enough_members" }; } const candidate = projectMainChatId(id); try { const probed = await getAppChat(config, candidate, fetchImpl); let chatId = candidate; if (!probed) { const created = await createAppChat( config, { name: `【总群】${project.name}`, userlist: members, chatid: candidate }, fetchImpl, ); chatId = created.chatid || candidate; } await db .prepare("UPDATE projects SET main_chat_id = ? WHERE id = ?") .bind(chatId, id) .run(); return { chatId, members }; } catch (error) { return { chatId: null, members, warning: `项目总群创建失败:${error instanceof Error ? error.message : String(error)}`, }; } } export async function listProjects( db: DatabaseClient, ownerUserid?: string, ): Promise { // undefined=全量;""=已登录未绑定企微(空范围,不能绑定 '' 否则漏出无主项目) if (ownerUserid !== undefined && String(ownerUserid).trim() === "") { return []; } const ownerFilter = String(ownerUserid ?? "").trim(); const result = await (ownerFilter ? db .prepare( `SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at, (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count FROM projects p WHERE p.owner_userid = ? ORDER BY p.created_at DESC`, ) .bind(ownerFilter) : db .prepare( `SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at, (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count FROM projects p ORDER BY p.created_at DESC`, ) ).all(); return result.results.map((row) => ({ id: row.id, name: row.name, brand: row.brand, status: row.status, note: row.note, createdAt: row.created_at, mainGroupMemberIds: parseJsonArray(row.main_group_member_ids), ownerUserid: row.owner_userid ?? "", mainChatId: row.main_chat_id ?? "", taskCount: Number(row.task_count), })); } export async function getProjectDetail( db: DatabaseClient, projectId: string, ): Promise { const id = text(projectId); const project = await db .prepare( `SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at FROM projects WHERE id = ?`, ) .bind(id) .first(); if (!project) throw new ProjectError("项目不存在", 404); const tasks = await db .prepare( `SELECT id, name, brand, quantity, claimed_quantity, due_at, status, task_type, platform, content_format, share_token, owner_userid, task_chat_id, recovery_enabled, recovery_due_at, (SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total, (SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted FROM tasks t WHERE t.project_id = ? ORDER BY t.created_at DESC`, ) .bind(id) .all(); return { project: { id: project.id, name: project.name, brand: project.brand, status: project.status, note: project.note, createdAt: project.created_at, mainGroupMemberIds: parseJsonArray(project.main_group_member_ids), ownerUserid: project.owner_userid ?? "", mainChatId: project.main_chat_id ?? "", taskCount: tasks.results.length, }, tasks: tasks.results, mainChat: { chatId: project.main_chat_id ?? "", members: effectiveProjectMembers( project.main_group_member_ids, project.owner_userid, ), }, }; } export type SaveProjectInput = { id?: unknown; name?: unknown; brand?: unknown; mainGroupMemberIds?: unknown; ownerUserid?: unknown; note?: unknown; }; export async function saveProject( db: DatabaseClient, input: SaveProjectInput, config?: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise<{ project: ProjectSummary; mainChat: MainChatEnsureResult }> { const id = text(input.id); const name = text(input.name); const brand = text(input.brand); const note = text(input.note ?? ""); const ownerUserid = text(input.ownerUserid).slice(0, 64); const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid); if (!name) throw new ProjectError("请填写项目名称"); const now = new Date().toISOString(); let projectId = id; if (id) { const result = await db .prepare( `UPDATE projects SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ? WHERE id = ?`, ) .bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id) .run(); if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404); } else { projectId = `proj-${crypto.randomUUID().slice(0, 12)}`; await db .prepare( `INSERT INTO projects (id, name, brand, main_group_member_ids, owner_userid, status, note, created_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`, ) .bind( projectId, name, brand, JSON.stringify(memberIds), ownerUserid, note, now, ) .run(); } const mainChat = config ? await ensureProjectMainChat(db, projectId, config, fetchImpl) : { chatId: null, members: memberIds, reason: "no_credentials" as MainChatEnsureReason, }; const detail = await getProjectDetail(db, projectId); return { project: detail.project, mainChat }; } export async function deleteProject( db: DatabaseClient, projectId: string, ): Promise { const id = text(projectId); if (!id) throw new ProjectError("缺少项目 ID"); const result = await db .prepare("DELETE FROM projects WHERE id = ?") .bind(id) .run(); if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404); await db .prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?") .bind(id) .run(); } export async function listGroupOwnerPartners( db: DatabaseClient, ): Promise { const result = await db .prepare( `SELECT id, name, wecom_userid, partner_type FROM partners WHERE partner_type = 'group_owner' ORDER BY created_at DESC`, ) .all(); return result.results; } export type SavePartnerInput = { id?: unknown; name?: unknown; wecomUserid?: unknown; }; export async function saveGroupOwnerPartner( db: DatabaseClient, input: SavePartnerInput, ): Promise { const id = text(input.id); const name = text(input.name); const wecomUserid = text(input.wecomUserid); if (!name) throw new ProjectError("请填写合作方名称"); if (!wecomUserid) throw new ProjectError("请填写企业微信 userid"); let partnerId = id; if (id) { const result = await db .prepare( `UPDATE partners SET name = ?, wecom_userid = ?, partner_type = 'group_owner' WHERE id = ?`, ) .bind(name, wecomUserid, id) .run(); if (result.meta.changes === 0) throw new ProjectError("合作方不存在", 404); } else { partnerId = `partner-go-${crypto.randomUUID().slice(0, 12)}`; await db .prepare( `INSERT INTO partners (id, name, wecom_name, owner, claimed_total, completed_total, partner_type, wecom_userid, created_at) VALUES (?, ?, '', '运营组', 0, 0, 'group_owner', ?, ?)`, ) .bind(partnerId, name, wecomUserid, new Date().toISOString()) .run(); } const row = await db .prepare( "SELECT id, name, wecom_userid, partner_type FROM partners WHERE id = ?", ) .bind(partnerId) .first(); if (!row) throw new ProjectError("合作方保存失败", 500); return row; } export async function findOrCreateGroupOwnerPartner( db: DatabaseClient, wecomUserid: string, ): Promise { const userid = text(wecomUserid).slice(0, 128); if (!userid) throw new ProjectError("缺少企业微信 userid"); const existing = await db .prepare( `SELECT id, name, wecom_userid, partner_type FROM partners WHERE partner_type = 'group_owner' AND wecom_userid = ? ORDER BY created_at LIMIT 1`, ) .bind(userid) .first(); if (existing) return existing; return saveGroupOwnerPartner(db, { name: userid, wecomUserid: userid }); }