95 lines
9.1 KiB
TypeScript
95 lines
9.1 KiB
TypeScript
import { Router, type Response } from 'express';
|
|
import { database } from '../database.js';
|
|
import { audit, canWriteProject, hashPassword, requireWriter, type AuthRequest } from '../auth.js';
|
|
import type { Project, WorkCollection } from '../../shared/types.js';
|
|
|
|
const router = Router();
|
|
const reader = requireWriter;
|
|
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
|
|
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
|
|
|
|
function projectSelect(where: string) {
|
|
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
|
|
p.customer_access_enabled, p.access_expires_at,
|
|
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
|
|
COALESCE(cc.collection_count, 0) AS collection_count,
|
|
COALESCE(wc.work_count, 0) AS work_count
|
|
FROM projects p
|
|
JOIN operation_groups g ON g.id = p.group_id
|
|
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
|
|
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
|
|
${where}`;
|
|
}
|
|
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; }
|
|
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
|
|
|
|
router.get('/', reader, async (req: AuthRequest, res: Response) => {
|
|
const platform = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform';
|
|
const projectKey = req.apiKey?.scope === 'project';
|
|
const where = platform
|
|
? 'WHERE p.status != ?'
|
|
: projectKey
|
|
? 'WHERE p.id = ? AND p.status != ?'
|
|
: 'WHERE p.group_id = ? AND p.status != ?';
|
|
const params = platform
|
|
? ['archived']
|
|
: projectKey
|
|
? [req.apiKey?.project_id, 'archived']
|
|
: [req.authUser?.group_id, 'archived'];
|
|
res.json((await database.all<ProjectRow>(`${projectSelect(where)} ORDER BY p.id DESC`, params)).map(projectJson));
|
|
});
|
|
|
|
router.post('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
|
const name=String(req.body?.name??'').trim(); const slug=String(req.body?.slug??'').trim().toLowerCase(); const description=String(req.body?.client_description??'').trim();
|
|
if(!name||!/^[a-z0-9-]+$/.test(slug)){res.status(400).json({error:'请填写项目名称,项目标识仅支持小写字母、数字和连字符'});return}
|
|
if(req.apiKey&&req.apiKey.scope!=='platform'){res.status(403).json({error:'项目级 API Key 不能创建项目'});return}
|
|
const groupId=req.authUser?.group_id??Number(req.body?.groupId??req.body?.group_id);
|
|
if(!await database.one('SELECT id FROM operation_groups WHERE id = ? AND status = ?', [groupId,'active'])){res.status(400).json({error:'请选择有效的运营组'});return}
|
|
let id: number;
|
|
try {
|
|
id=await database.insertId('INSERT INTO projects (name, slug, client_description, group_id) VALUES (?, ?, ?, ?)',[name,slug,description,groupId]);
|
|
} catch {
|
|
res.status(409).json({error:'项目标识已存在'});return;
|
|
}
|
|
await audit(req,'project.create','project',id,{name,slug});
|
|
res.status(201).json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
|
|
});
|
|
|
|
router.get('/:projectId', reader, async (req: AuthRequest,res:Response)=>{
|
|
const id=Number(req.params.projectId); if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
|
|
const row=await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]); if(!row){res.status(404).json({error:'项目不存在'});return} res.json(projectJson(row));
|
|
});
|
|
|
|
router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
|
|
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
|
|
const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'项目名称不能为空'});return}
|
|
if(!(await database.execute('UPDATE projects SET name = ?, client_description = ? WHERE id = ?',[name,description,id])).changes){res.status(404).json({error:'项目不存在'});return}
|
|
await audit(req,'project.update','project',id,{name});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
|
|
});
|
|
|
|
router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{
|
|
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
|
|
const rows=await database.all<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
|
|
});
|
|
|
|
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
|
|
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
|
|
const enabled=req.body?.enabled===true;const password=String(req.body?.password??'');const expiresAt=String(req.body?.expires_at??'').trim()||null;
|
|
const current=await database.one<{access_password_hash:string}>('SELECT access_password_hash FROM projects WHERE id = ?',[id]);if(!current){res.status(404).json({error:'项目不存在'});return}
|
|
if(password&&password.length<6){res.status(400).json({error:'客户访问密码至少 6 位'});return}if(enabled&&!password&&!current.access_password_hash){res.status(400).json({error:'启用客户访问前请设置访问密码'});return}if(expiresAt&&!Number.isFinite(new Date(expiresAt).getTime())){res.status(400).json({error:'到期时间格式无效'});return}
|
|
await database.execute('UPDATE projects SET customer_access_enabled = ?, access_password_hash = ?, access_expires_at = ? WHERE id = ?',[enabled,password?hashPassword(password):current.access_password_hash,expiresAt,id]);if(!enabled)await database.execute('DELETE FROM customer_sessions WHERE project_id = ?',[id]);
|
|
await audit(req,'project.customer_access_update','project',id,{enabled,passwordReset:Boolean(password),expiresAt});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
|
|
});
|
|
|
|
router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{
|
|
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
|
|
try{const id=await database.insertId('INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)',[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
|
|
});
|
|
|
|
router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{
|
|
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
|
|
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
|
|
});
|
|
|
|
export default router;
|