2026-08-31 16:35:17 +08:00
|
|
|
|
import type { DatabaseClient } from "./database";
|
2026-09-04 18:03:46 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createAppChat,
|
|
|
|
|
|
getAppChat,
|
|
|
|
|
|
hasAppCredentials,
|
|
|
|
|
|
sanitizeChatid,
|
|
|
|
|
|
updateAppChat,
|
|
|
|
|
|
type WecomConfig,
|
|
|
|
|
|
} from "./wecom-client";
|
|
|
|
|
|
|
|
|
|
|
|
type FetchLike = typeof fetch;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
|
|
|
|
|
|
export type ProjectRow = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
brand: string;
|
|
|
|
|
|
main_group_member_ids: string;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
owner_userid: string;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
main_chat_id: string;
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parent_id: string;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
status: string;
|
|
|
|
|
|
note: string;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectSummary = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
brand: string;
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
note: string;
|
|
|
|
|
|
createdAt: string;
|
|
|
|
|
|
mainGroupMemberIds: string[];
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid: string;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
mainChatId: string;
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parentProjectId: string;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
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;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
owner_userid: string;
|
|
|
|
|
|
task_chat_id: string;
|
2026-09-23 11:13:17 +08:00
|
|
|
|
project_name?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectSubproject = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
brand: string;
|
|
|
|
|
|
ownerUserid: string;
|
|
|
|
|
|
mainChatId: string;
|
|
|
|
|
|
taskCount: number;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
export type ProjectMainChat = {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
chatId: string;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
members: string[];
|
2026-08-31 16:35:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectDetail = {
|
|
|
|
|
|
project: ProjectSummary;
|
|
|
|
|
|
tasks: ProjectTaskRow[];
|
2026-09-04 18:03:46 +08:00
|
|
|
|
mainChat: ProjectMainChat;
|
2026-09-23 11:13:17 +08:00
|
|
|
|
subprojects: ProjectSubproject[];
|
|
|
|
|
|
pushTasks: ProjectTaskRow[];
|
2026-09-04 18:03:46 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type MainChatEnsureReason =
|
|
|
|
|
|
| "no_project"
|
|
|
|
|
|
| "no_credentials"
|
|
|
|
|
|
| "not_enough_members";
|
|
|
|
|
|
|
|
|
|
|
|
export type MainChatEnsureResult = {
|
|
|
|
|
|
chatId: string | null;
|
|
|
|
|
|
members: string[];
|
|
|
|
|
|
reason?: MainChatEnsureReason;
|
|
|
|
|
|
warning?: string;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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<string>();
|
|
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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<MainChatEnsureResult> {
|
|
|
|
|
|
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<ProjectRow>();
|
|
|
|
|
|
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)}`,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 11:13:17 +08:00
|
|
|
|
async function loadProjectParents(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
): Promise<Map<string, string>> {
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.prepare("SELECT id, parent_id FROM projects")
|
|
|
|
|
|
.all<{ id: string; parent_id: string }>();
|
|
|
|
|
|
return new Map(result.results.map((row) => [row.id, text(row.parent_id)]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// candidate 是否位于 ancestor 的子孙链上(沿 candidate 的祖先链向上找 ancestor)
|
|
|
|
|
|
function isDescendantProject(
|
|
|
|
|
|
parents: Map<string, string>,
|
|
|
|
|
|
ancestorId: string,
|
|
|
|
|
|
candidateId: string,
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
let current = candidateId;
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
while (current) {
|
|
|
|
|
|
if (current === ancestorId) return true;
|
|
|
|
|
|
if (seen.has(current)) return false;
|
|
|
|
|
|
seen.add(current);
|
|
|
|
|
|
current = parents.get(current) ?? "";
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function collectDescendantIds(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
): Promise<string[]> {
|
|
|
|
|
|
const parents = await loadProjectParents(db);
|
|
|
|
|
|
const childrenOf = new Map<string, string[]>();
|
|
|
|
|
|
for (const [id, parent] of parents) {
|
|
|
|
|
|
if (!parent) continue;
|
|
|
|
|
|
const list = childrenOf.get(parent) ?? [];
|
|
|
|
|
|
list.push(id);
|
|
|
|
|
|
childrenOf.set(parent, list);
|
|
|
|
|
|
}
|
|
|
|
|
|
const descendants: string[] = [];
|
|
|
|
|
|
const queue = [text(projectId)];
|
|
|
|
|
|
const visited = new Set(queue);
|
|
|
|
|
|
while (queue.length > 0) {
|
|
|
|
|
|
const current = queue.shift() as string;
|
|
|
|
|
|
for (const child of childrenOf.get(current) ?? []) {
|
|
|
|
|
|
if (visited.has(child)) continue;
|
|
|
|
|
|
visited.add(child);
|
|
|
|
|
|
descendants.push(child);
|
|
|
|
|
|
queue.push(child);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return descendants;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 15:21:24 +08:00
|
|
|
|
export async function listProjects(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
ownerUserid?: string,
|
|
|
|
|
|
): Promise<ProjectSummary[]> {
|
|
|
|
|
|
// undefined=全量;""=已登录未绑定企微(空范围,不能绑定 '' 否则漏出无主项目)
|
|
|
|
|
|
if (ownerUserid !== undefined && String(ownerUserid).trim() === "") {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
const ownerFilter = String(ownerUserid ?? "").trim();
|
|
|
|
|
|
const result = await (ownerFilter
|
|
|
|
|
|
? db
|
|
|
|
|
|
.prepare(
|
2026-09-23 11:13:17 +08:00
|
|
|
|
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
|
2026-09-17 15:21:24 +08:00
|
|
|
|
(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(
|
2026-09-23 11:13:17 +08:00
|
|
|
|
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
|
2026-09-17 15:21:24 +08:00
|
|
|
|
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
|
|
|
|
|
FROM projects p
|
|
|
|
|
|
ORDER BY p.created_at DESC`,
|
|
|
|
|
|
)
|
|
|
|
|
|
).all<ProjectRow & { task_count: number }>();
|
2026-08-31 16:35:17 +08:00
|
|
|
|
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),
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid: row.owner_userid ?? "",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
mainChatId: row.main_chat_id ?? "",
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parentProjectId: row.parent_id ?? "",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
taskCount: Number(row.task_count),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getProjectDetail(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
): Promise<ProjectDetail> {
|
|
|
|
|
|
const id = text(projectId);
|
|
|
|
|
|
const project = await db
|
|
|
|
|
|
.prepare(
|
2026-09-23 11:13:17 +08:00
|
|
|
|
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, parent_id, status, note, created_at
|
2026-08-31 16:35:17 +08:00
|
|
|
|
FROM projects WHERE id = ?`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.first<ProjectRow>();
|
|
|
|
|
|
if (!project) throw new ProjectError("项目不存在", 404);
|
|
|
|
|
|
|
|
|
|
|
|
const tasks = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_type, platform, content_format, share_token, owner_userid, task_chat_id
|
2026-08-31 16:35:17 +08:00
|
|
|
|
FROM tasks WHERE project_id = ?
|
|
|
|
|
|
ORDER BY created_at DESC`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.all<ProjectTaskRow>();
|
|
|
|
|
|
|
2026-09-23 11:13:17 +08:00
|
|
|
|
const subprojects = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`SELECT p.id, p.name, p.brand, p.owner_userid, p.main_chat_id,
|
|
|
|
|
|
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
|
|
|
|
|
FROM projects p
|
|
|
|
|
|
WHERE p.parent_id = ?
|
|
|
|
|
|
ORDER BY p.created_at DESC`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.all<ProjectRow & { task_count: number }>();
|
|
|
|
|
|
|
|
|
|
|
|
const descendantIds = await collectDescendantIds(db, id);
|
|
|
|
|
|
const pushIds = [id, ...descendantIds];
|
|
|
|
|
|
const placeholders = pushIds.map(() => "?").join(", ");
|
|
|
|
|
|
const pushTasks = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`SELECT t.id, t.name, t.brand, t.quantity, t.claimed_quantity, t.due_at, t.status,
|
|
|
|
|
|
t.task_type, t.platform, t.content_format, t.share_token, t.owner_userid, t.task_chat_id,
|
|
|
|
|
|
pj.name AS project_name
|
|
|
|
|
|
FROM tasks t
|
|
|
|
|
|
LEFT JOIN projects pj ON pj.id = t.project_id
|
|
|
|
|
|
WHERE t.project_id IN (${placeholders})
|
|
|
|
|
|
ORDER BY t.created_at DESC`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(...pushIds)
|
|
|
|
|
|
.all<ProjectTaskRow>();
|
|
|
|
|
|
|
2026-08-31 16:35:17 +08:00
|
|
|
|
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),
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid: project.owner_userid ?? "",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
mainChatId: project.main_chat_id ?? "",
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parentProjectId: project.parent_id ?? "",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
taskCount: tasks.results.length,
|
|
|
|
|
|
},
|
|
|
|
|
|
tasks: tasks.results,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
mainChat: {
|
|
|
|
|
|
chatId: project.main_chat_id ?? "",
|
|
|
|
|
|
members: effectiveProjectMembers(
|
|
|
|
|
|
project.main_group_member_ids,
|
|
|
|
|
|
project.owner_userid,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2026-09-23 11:13:17 +08:00
|
|
|
|
subprojects: subprojects.results.map((row) => ({
|
|
|
|
|
|
id: row.id,
|
|
|
|
|
|
name: row.name,
|
|
|
|
|
|
brand: row.brand,
|
|
|
|
|
|
ownerUserid: row.owner_userid ?? "",
|
|
|
|
|
|
mainChatId: row.main_chat_id ?? "",
|
|
|
|
|
|
taskCount: Number(row.task_count),
|
|
|
|
|
|
})),
|
|
|
|
|
|
pushTasks: pushTasks.results,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type SaveProjectInput = {
|
|
|
|
|
|
id?: unknown;
|
|
|
|
|
|
name?: unknown;
|
|
|
|
|
|
brand?: unknown;
|
|
|
|
|
|
mainGroupMemberIds?: unknown;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid?: unknown;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
note?: unknown;
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parentProjectId?: unknown;
|
2026-08-31 16:35:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function saveProject(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
input: SaveProjectInput,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
config?: WecomConfig,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<{ project: ProjectSummary; mainChat: MainChatEnsureResult }> {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const id = text(input.id);
|
|
|
|
|
|
const name = text(input.name);
|
|
|
|
|
|
const brand = text(input.brand);
|
|
|
|
|
|
const note = text(input.note ?? "");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
const ownerUserid = text(input.ownerUserid).slice(0, 64);
|
2026-09-23 11:13:17 +08:00
|
|
|
|
const parentProjectId = text(input.parentProjectId);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (!name) throw new ProjectError("请填写项目名称");
|
|
|
|
|
|
|
2026-09-23 11:13:17 +08:00
|
|
|
|
if (parentProjectId) {
|
|
|
|
|
|
if (parentProjectId === id) {
|
|
|
|
|
|
throw new ProjectError("上级项目不能是项目自身");
|
|
|
|
|
|
}
|
|
|
|
|
|
const parents = await loadProjectParents(db);
|
|
|
|
|
|
if (!parents.has(parentProjectId)) {
|
|
|
|
|
|
throw new ProjectError("上级项目不存在", 404);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (id && isDescendantProject(parents, id, parentProjectId)) {
|
|
|
|
|
|
throw new ProjectError("上级项目不能是当前项目的子项目");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
let projectId = id;
|
|
|
|
|
|
if (id) {
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`UPDATE projects
|
2026-09-23 11:13:17 +08:00
|
|
|
|
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?, parent_id = ?
|
2026-08-31 16:35:17 +08:00
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
|
)
|
2026-09-23 11:13:17 +08:00
|
|
|
|
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id)
|
2026-08-31 16:35:17 +08:00
|
|
|
|
.run();
|
|
|
|
|
|
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
projectId = `proj-${crypto.randomUUID().slice(0, 12)}`;
|
|
|
|
|
|
await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`INSERT INTO projects
|
2026-09-23 11:13:17 +08:00
|
|
|
|
(id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
.bind(
|
|
|
|
|
|
projectId,
|
|
|
|
|
|
name,
|
|
|
|
|
|
brand,
|
|
|
|
|
|
JSON.stringify(memberIds),
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid,
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parentProjectId,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
note,
|
|
|
|
|
|
now,
|
|
|
|
|
|
)
|
|
|
|
|
|
.run();
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const mainChat = config
|
|
|
|
|
|
? await ensureProjectMainChat(db, projectId, config, fetchImpl)
|
|
|
|
|
|
: {
|
|
|
|
|
|
chatId: null,
|
|
|
|
|
|
members: memberIds,
|
|
|
|
|
|
reason: "no_credentials" as MainChatEnsureReason,
|
|
|
|
|
|
};
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const detail = await getProjectDetail(db, projectId);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
return { project: detail.project, mainChat };
|
2026-08-31 16:35:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteProject(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const id = text(projectId);
|
|
|
|
|
|
if (!id) throw new ProjectError("缺少项目 ID");
|
2026-09-23 11:13:17 +08:00
|
|
|
|
const childCount = await db
|
|
|
|
|
|
.prepare("SELECT COUNT(*) AS count FROM projects WHERE parent_id = ?")
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.first<{ count: number }>();
|
|
|
|
|
|
if (Number(childCount?.count ?? 0) > 0) {
|
|
|
|
|
|
throw new ProjectError("该项目下还有子项目,请先删除子项目或把它们移到其他项目下");
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const result = await db
|
|
|
|
|
|
.prepare("DELETE FROM projects WHERE id = ?")
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.run();
|
|
|
|
|
|
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
await db
|
|
|
|
|
|
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.run();
|
2026-08-31 16:35:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function listGroupOwnerPartners(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
): Promise<GroupOwnerPartnerRow[]> {
|
|
|
|
|
|
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<GroupOwnerPartnerRow>();
|
|
|
|
|
|
return result.results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type SavePartnerInput = {
|
|
|
|
|
|
id?: unknown;
|
|
|
|
|
|
name?: unknown;
|
|
|
|
|
|
wecomUserid?: unknown;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function saveGroupOwnerPartner(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
input: SavePartnerInput,
|
|
|
|
|
|
): Promise<GroupOwnerPartnerRow> {
|
|
|
|
|
|
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<GroupOwnerPartnerRow>();
|
|
|
|
|
|
if (!row) throw new ProjectError("合作方保存失败", 500);
|
|
|
|
|
|
return row;
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
|
|
|
|
|
|
export async function findOrCreateGroupOwnerPartner(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
wecomUserid: string,
|
|
|
|
|
|
): Promise<GroupOwnerPartnerRow> {
|
|
|
|
|
|
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<GroupOwnerPartnerRow>();
|
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
return saveGroupOwnerPartner(db, { name: userid, wecomUserid: userid });
|
|
|
|
|
|
}
|