Improve membership flow, play library, and mobile tactics board

This commit is contained in:
2026-09-08 18:55:48 +09:00
parent aa5ce697ad
commit 680cb21c80
15 changed files with 1136 additions and 82 deletions
+71
View File
@@ -18,6 +18,7 @@ function iso(value) { return value instanceof Date ? value.toISOString() : new D
function safeText(value, fallback = '') { return String(value ?? '').trim() || fallback; }
function publicUser(row) { return { id: row.id, email: row.email, displayName: row.displayName, status: row.status, isOperator: Boolean(row.isOperator) }; }
function publicTeam(row) { return { id: row.id, name: row.name, ownerUserId: row.ownerUserId, role: row.role, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
function publicJoinRequest(row) { return { id: row.id, teamId: row.teamId, userId: row.userId, status: row.status, role: row.role || null, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
function errorPayload(code, message) { return { error: { code, message } }; }
function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); }
function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); }
@@ -159,6 +160,76 @@ export async function buildServer(options = {}) {
return reply.code(201).send({ team: publicTeam({ id, name, ownerUserId: request.user.id, role: 'owner', createdAt, updatedAt: createdAt }) });
});
app.get('/api/teams/search', { preHandler: requireUser }, async (request) => {
const query = safeText(request.query?.q).slice(0, 60);
if (query.length < 2) return { teams: [] };
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const rows = await store.teams.find({ name: { $regex: escaped, $options: 'i' } }, { projection: { _id: 0, id: 1, name: 1 } }).sort({ name: 1 }).limit(20).toArray();
return { teams: rows.slice(0, 20).map((team) => ({ id: team.id, name: team.name })) };
});
app.get('/api/team-requests', { preHandler: requireUser }, async (request) => {
const rows = await store.joinRequests.find({ userId: request.user.id }).sort({ updatedAt: -1 }).toArray();
const teams = await store.teams.find({ id: { $in: rows.map((row) => row.teamId) } }, { projection: { _id: 0, id: 1, name: 1 } }).toArray();
const names = new Map(teams.map((team) => [team.id, team.name]));
return { requests: rows.map((row) => ({ ...publicJoinRequest(row), teamName: names.get(row.teamId) || '삭제된 팀' })) };
});
app.post('/api/teams/:teamId/join-requests', { preHandler: requireUser, schema: { body: bodySchema({}, []) } }, async (request, reply) => {
if (!parsePositiveId(request.params.teamId)) return fail(reply, 400, 'invalid_team', '팀 식별자가 올바르지 않습니다');
const team = await store.teams.findOne({ id: request.params.teamId }); if (!team) return fail(reply, 404, 'team_not_found', '팀을 찾을 수 없습니다');
if (await store.teamMembers.findOne({ teamId: team.id, userId: request.user.id })) return fail(reply, 409, 'already_member', '이미 팀에 속해 있습니다');
const existing = await store.joinRequests.findOne({ teamId: team.id, userId: request.user.id });
if (existing) {
if (existing.status === 'pending' || existing.status === 'approved') return { request: publicJoinRequest(existing), existing: true };
const at = nowDate();
await store.joinRequests.updateOne({ id: existing.id, teamId: team.id, userId: request.user.id }, { $set: { status: 'pending', role: null, updatedAt: at } });
return { request: publicJoinRequest({ ...existing, status: 'pending', role: null, updatedAt: at }), existing: true };
}
const at = nowDate(); const row = { id: randomUUID(), teamId: team.id, userId: request.user.id, status: 'pending', role: null, createdAt: at, updatedAt: at };
try { await store.joinRequests.insertOne(row); } catch (error) { if (error?.code === 11000) { const current = await store.joinRequests.findOne({ teamId: team.id, userId: request.user.id }); return { request: publicJoinRequest(current), existing: true }; } throw error; }
return reply.code(201).send({ request: publicJoinRequest(row), existing: false });
});
app.get('/api/teams/:teamId/members', { preHandler: requireTeam('owner') }, async (request) => {
const members = await store.teamMembers.find({ teamId: request.params.teamId }).toArray();
const users = await store.users.find({ id: { $in: members.map((member) => member.userId) } }).toArray();
const byId = new Map(users.map((user) => [user.id, user]));
return { members: members.map((member) => ({ user: publicUser(byId.get(member.userId) || { id: member.userId, email: '', displayName: member.userId, status: 'unknown', isOperator: false }), role: member.role, createdAt: iso(member.createdAt) })) };
});
app.get('/api/teams/:teamId/join-requests', { preHandler: requireTeam('owner') }, async (request) => {
const rows = await store.joinRequests.find({ teamId: request.params.teamId, status: 'pending' }).sort({ createdAt: 1 }).toArray();
const users = await store.users.find({ id: { $in: rows.map((row) => row.userId) } }).toArray(); const byId = new Map(users.map((user) => [user.id, user]));
return { requests: rows.map((row) => ({ ...publicJoinRequest(row), user: publicUser(byId.get(row.userId) || { id: row.userId, email: '', displayName: row.userId, status: 'unknown', isOperator: false }) })) };
});
app.post('/api/teams/:teamId/join-requests/:requestId/approve', { preHandler: requireTeam('owner'), schema: { body: bodySchema({ role: { type: 'string', enum: ['viewer', 'editor'] } }, []) } }, async (request, reply) => {
if (request.team.ownerUserId !== request.user.id) return fail(reply, 403, 'team_forbidden', '팀 소유자만 가입 요청을 승인할 수 있습니다');
const requestedRole = request.body?.role || 'viewer'; const requestRow = await store.joinRequests.findOne({ id: request.params.requestId, teamId: request.params.teamId });
if (requestRow?.status === 'approved') {
const existingMember = await store.teamMembers.findOne({ teamId: request.params.teamId, userId: requestRow.userId });
if (existingMember) return { ok: true, existing: true, member: { user: publicUser(await store.users.findOne({ id: requestRow.userId }) || { id: requestRow.userId, email: '', displayName: requestRow.userId, status: 'unknown', isOperator: false }), role: existingMember.role } };
}
if (requestRow?.status !== 'pending') return fail(reply, 404, 'request_not_found', '가입 요청을 찾을 수 없습니다');
const applicant = await store.users.findOne({ id: requestRow.userId, status: 'approved' });
if (!applicant) return fail(reply, 409, 'applicant_not_approved', '승인된 계정만 팀에 추가할 수 있습니다');
const at = nowDate();
let memberRole = requestedRole;
try { await store.teamMembers.insertOne({ teamId: request.params.teamId, userId: requestRow.userId, role: requestedRole, createdAt: at }); }
catch (error) { if (error?.code !== 11000) throw error; const existing = await store.teamMembers.findOne({ teamId: request.params.teamId, userId: requestRow.userId }); memberRole = existing?.role || requestedRole; }
await store.joinRequests.updateOne({ id: requestRow.id, status: 'pending' }, { $set: { status: 'approved', role: memberRole, updatedAt: at } });
return { ok: true, member: { user: publicUser(applicant), role: memberRole } };
});
app.patch('/api/teams/:teamId/members/:userId', { preHandler: requireTeam('owner'), schema: { body: bodySchema({ role: { type: 'string', enum: ['viewer', 'editor'] } }, ['role']) } }, async (request, reply) => {
if (request.team.ownerUserId !== request.user.id) return fail(reply, 403, 'team_forbidden', '팀 소유자만 멤버 권한을 변경할 수 있습니다');
const userId = request.params.userId; if (userId === request.user.id) return fail(reply, 400, 'owner_immutable', '소유자 권한은 변경할 수 없습니다');
const member = await store.teamMembers.findOne({ teamId: request.params.teamId, userId }); if (!member) return fail(reply, 404, 'member_not_found', '팀 멤버를 찾을 수 없습니다');
if (member.role === 'owner') return fail(reply, 400, 'owner_immutable', '소유자 권한은 변경할 수 없습니다');
await store.teamMembers.updateOne({ teamId: request.params.teamId, userId }, { $set: { role: request.body.role, updatedAt: nowDate() } }); return { ok: true, userId, role: request.body.role };
});
app.get('/api/teams/:teamId', { preHandler: requireTeam('viewer') }, async (request) => ({ team: publicTeam(request.team) }));
app.get('/api/teams/:teamId/plays', { preHandler: requireTeam('viewer') }, async (request) => {