2026-08-31 16:35:17 +08:00
|
|
|
|
import type { DatabaseClient } from "./database";
|
|
|
|
|
|
|
|
|
|
|
|
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-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-08-31 16:35:17 +08:00
|
|
|
|
taskCount: number;
|
|
|
|
|
|
groupChatCount: 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectGroupChat = {
|
|
|
|
|
|
chatId: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
ownerUserId: string;
|
|
|
|
|
|
memberCount: number;
|
|
|
|
|
|
status: number;
|
|
|
|
|
|
partnerId: string;
|
|
|
|
|
|
partnerName: string;
|
|
|
|
|
|
missing: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectDetail = {
|
|
|
|
|
|
project: ProjectSummary;
|
|
|
|
|
|
tasks: ProjectTaskRow[];
|
|
|
|
|
|
groupChats: ProjectGroupChat[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.prepare(
|
2026-09-03 11:30:26 +08:00
|
|
|
|
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.status, p.note, p.created_at,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count,
|
|
|
|
|
|
(SELECT COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
|
|
|
|
|
|
FROM projects p
|
|
|
|
|
|
ORDER BY p.created_at DESC`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.all<ProjectRow & { task_count: number; group_chat_count: number }>();
|
|
|
|
|
|
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-08-31 16:35:17 +08:00
|
|
|
|
taskCount: Number(row.task_count),
|
|
|
|
|
|
groupChatCount: Number(row.group_chat_count),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getProjectDetail(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
): Promise<ProjectDetail> {
|
|
|
|
|
|
const id = text(projectId);
|
|
|
|
|
|
const project = await db
|
|
|
|
|
|
.prepare(
|
2026-09-03 11:30:26 +08:00
|
|
|
|
`SELECT id, name, brand, main_group_member_ids, owner_userid, 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,
|
|
|
|
|
|
task_type, platform, content_format, share_token
|
|
|
|
|
|
FROM tasks WHERE project_id = ?
|
|
|
|
|
|
ORDER BY created_at DESC`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.all<ProjectTaskRow>();
|
|
|
|
|
|
|
|
|
|
|
|
const chats = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`SELECT g.chat_id, g.partner_id,
|
|
|
|
|
|
c.name AS chat_name, c.owner_user_id, c.member_count, c.status AS chat_status,
|
|
|
|
|
|
p.name AS partner_name
|
|
|
|
|
|
FROM project_group_chats g
|
|
|
|
|
|
LEFT JOIN wecom_group_chats c ON c.chat_id = g.chat_id
|
|
|
|
|
|
LEFT JOIN partners p ON p.id = g.partner_id
|
|
|
|
|
|
WHERE g.project_id = ?
|
|
|
|
|
|
ORDER BY g.created_at`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.all<{
|
|
|
|
|
|
chat_id: string;
|
|
|
|
|
|
partner_id: string;
|
|
|
|
|
|
chat_name: string | null;
|
|
|
|
|
|
owner_user_id: string | null;
|
|
|
|
|
|
member_count: number | null;
|
|
|
|
|
|
chat_status: number | null;
|
|
|
|
|
|
partner_name: string | null;
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
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-08-31 16:35:17 +08:00
|
|
|
|
taskCount: tasks.results.length,
|
|
|
|
|
|
groupChatCount: chats.results.length,
|
|
|
|
|
|
},
|
|
|
|
|
|
tasks: tasks.results,
|
|
|
|
|
|
groupChats: chats.results.map((row) => ({
|
|
|
|
|
|
chatId: row.chat_id,
|
|
|
|
|
|
name: row.chat_name ?? "",
|
|
|
|
|
|
ownerUserId: row.owner_user_id ?? "",
|
|
|
|
|
|
memberCount: Number(row.member_count ?? 0),
|
|
|
|
|
|
status: Number(row.chat_status ?? 0),
|
|
|
|
|
|
partnerId: row.partner_id,
|
|
|
|
|
|
partnerName: row.partner_name ?? "",
|
|
|
|
|
|
missing: row.chat_name === null,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function saveProject(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
input: SaveProjectInput,
|
|
|
|
|
|
): Promise<ProjectSummary> {
|
|
|
|
|
|
const id = text(input.id);
|
|
|
|
|
|
const name = text(input.name);
|
|
|
|
|
|
const brand = text(input.brand);
|
|
|
|
|
|
const note = text(input.note ?? "");
|
|
|
|
|
|
const memberIds = parseMemberIds(input.mainGroupMemberIds);
|
2026-09-03 11:30:26 +08:00
|
|
|
|
const ownerUserid = text(input.ownerUserid).slice(0, 64);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (!name) throw new ProjectError("请填写项目名称");
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
let projectId = id;
|
|
|
|
|
|
if (id) {
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`UPDATE projects
|
2026-09-03 11:30:26 +08:00
|
|
|
|
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
|
2026-08-31 16:35:17 +08:00
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
|
)
|
2026-09-03 11:30:26 +08:00
|
|
|
|
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, 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-03 11:30:26 +08:00
|
|
|
|
(id, name, brand, main_group_member_ids, owner_userid, 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-08-31 16:35:17 +08:00
|
|
|
|
note,
|
|
|
|
|
|
now,
|
|
|
|
|
|
)
|
|
|
|
|
|
.run();
|
|
|
|
|
|
}
|
|
|
|
|
|
const detail = await getProjectDetail(db, projectId);
|
|
|
|
|
|
return detail.project;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteProject(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
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.batch([
|
|
|
|
|
|
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
|
|
|
|
|
|
db
|
|
|
|
|
|
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
|
|
|
|
|
|
.bind(id),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectGroupChatInput = {
|
|
|
|
|
|
chatId: string;
|
|
|
|
|
|
partnerId?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function setProjectGroupChats(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
items: ProjectGroupChatInput[],
|
|
|
|
|
|
): Promise<ProjectDetail> {
|
|
|
|
|
|
const id = text(projectId);
|
|
|
|
|
|
await getProjectDetail(db, id);
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
const rows: ProjectGroupChatInput[] = [];
|
|
|
|
|
|
for (const item of Array.isArray(items) ? items : []) {
|
|
|
|
|
|
const chatId = text(item?.chatId);
|
|
|
|
|
|
if (!chatId || seen.has(chatId)) continue;
|
|
|
|
|
|
seen.add(chatId);
|
|
|
|
|
|
rows.push({ chatId, partnerId: text(item?.partnerId) });
|
|
|
|
|
|
}
|
|
|
|
|
|
const statements = [
|
|
|
|
|
|
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
|
|
|
|
|
|
...rows.map((row) =>
|
|
|
|
|
|
db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`INSERT INTO project_group_chats
|
|
|
|
|
|
(id, project_id, chat_id, partner_id, created_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(
|
|
|
|
|
|
`pgc-${crypto.randomUUID().slice(0, 12)}`,
|
|
|
|
|
|
id,
|
|
|
|
|
|
row.chatId,
|
|
|
|
|
|
row.partnerId ?? "",
|
|
|
|
|
|
new Date().toISOString(),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
await db.batch(statements);
|
|
|
|
|
|
return getProjectDetail(db, id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type ProjectPushTargets = {
|
|
|
|
|
|
project: { id: string; name: string; mainGroupMemberIds: string[] };
|
|
|
|
|
|
chatIds: string[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function getProjectPushTargets(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
projectId: string,
|
|
|
|
|
|
): Promise<ProjectPushTargets> {
|
|
|
|
|
|
const detail = await getProjectDetail(db, projectId);
|
|
|
|
|
|
return {
|
|
|
|
|
|
project: {
|
|
|
|
|
|
id: detail.project.id,
|
|
|
|
|
|
name: detail.project.name,
|
|
|
|
|
|
mainGroupMemberIds: detail.project.mainGroupMemberIds,
|
|
|
|
|
|
},
|
|
|
|
|
|
chatIds: detail.groupChats.filter((chat) => !chat.missing).map((chat) => chat.chatId),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|