v4.0.0: 회원·덱 API, 인증 UI, 프로필 및 레이아웃 통일

Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 16:59:36 +09:00
parent f8c954f24b
commit dbf02241e2
56 changed files with 6732 additions and 183 deletions

250
api/src/routes/admin.js Normal file
View File

@@ -0,0 +1,250 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { createRequireAdmin } from '../middleware/auth.js';
import { contentHash, validateCards, nowIso } from '../utils.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { getPreviewUrl } from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
* 관리자 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createAdminRouter(db) {
const router = Router();
router.use(createRequireAdmin(db));
router.get('/users', (req, res) => {
const { q, page = 1, limit = 20 } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let rows;
let total;
if (q) {
const like = `%${q}%`;
total = db.prepare(`
SELECT COUNT(*) AS c FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
`).get(like, like, like).c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(like, like, like, Number(limit), offset);
} else {
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(Number(limit), offset);
}
res.json({
users: rows.map((u) => ({
...u,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
})),
total,
page: Number(page),
limit: Number(limit),
});
});
router.get('/users/:id', (req, res) => {
const user = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.params.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c;
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
res.json({ user, deckCount });
});
router.put('/users/:id', (req, res) => {
const targetId = Number(req.params.id);
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const isSelf = req.user.id === targetId;
const { email, username, role, isActive, avatarImage, removeAvatar } = req.body || {};
if (isSelf) {
if (role === 'user') {
return res.status(400).json({ error: '자신의 관리자 권한은 해제할 수 없습니다.' });
}
if (isActive === false) {
return res.status(400).json({ error: '자신의 계정은 비활성화할 수 없습니다.' });
}
}
let nextAvatarUrl;
if (avatarImage) {
const saved = saveAvatarImage(targetId, avatarImage);
if (!saved) {
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
}
nextAvatarUrl = getAvatarUrl(targetId);
} else if (removeAvatar === true) {
deleteAvatarImage(targetId);
nextAvatarUrl = null;
}
const trimmedUsername = username?.trim() ?? null;
const trimmedEmail = email?.trim().toLowerCase() ?? null;
try {
const fields = ['updated_at = ?'];
const values = [nowIso()];
if (trimmedEmail) {
fields.push('email = ?');
values.push(trimmedEmail);
}
if (trimmedUsername) {
fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername);
}
if (!isSelf && role) {
fields.push('role = ?');
values.push(role);
}
if (!isSelf && isActive !== undefined) {
fields.push('is_active = ?');
values.push(isActive ? 1 : 0);
}
if (nextAvatarUrl !== undefined) {
fields.push('avatar_url = ?');
values.push(nextAvatarUrl);
}
values.push(targetId);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
} catch (err) {
if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이메일 또는 사용자명이 이미 사용 중입니다.' });
}
throw err;
}
const updated = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(targetId);
updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl;
res.json({ user: updated });
});
router.post('/users/:id/reset-password', (req, res) => {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const { newPassword } = req.body || {};
if (!newPassword || newPassword.length < 8) {
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
}
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hash, nowIso(), req.params.id);
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
});
router.post('/decks/preset', async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic } = req.body || {};
if (!series || !cards || !name) {
return res.status(400).json({ error: 'series, name, cards는 필수입니다.' });
}
const normalized = validateCards(cards);
const hash = contentHash(normalized);
const existing = db.prepare('SELECT share_code FROM decks WHERE content_hash = ? AND share_type = ?').get(hash, 'preset');
if (existing) {
return res.status(409).json({
error: '동일한 구성의 프리셋이 이미 존재합니다.',
shareCode: existing.share_code,
});
}
const cardsJson = JSON.stringify(normalized);
db.prepare(`
INSERT INTO decks (share_code, share_type, content_hash, user_id, series, series_name, name, cards_json, is_public)
VALUES (?, 'preset', ?, ?, ?, ?, ?, ?, ?)
`).run(
hash,
hash,
req.user.id,
series,
seriesName || series,
name,
cardsJson,
isPublic === false ? 0 : 1,
);
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(hash);
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
res.status(201).json({
v: 1,
shareCode: row.share_code,
shareType: 'preset',
series: row.series,
seriesName: row.series_name,
name: row.name,
totalCards,
cards: resolved,
});
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.get('/decks', (req, res) => {
const rows = db.prepare(`
SELECT d.id, d.share_code AS shareCode, d.share_type AS shareType, d.series, d.series_name AS seriesName,
d.name, d.is_public AS isPublic, d.view_count AS viewCount, d.created_at AS createdAt,
d.preview_image AS previewImage, d.updated_at AS updatedAt,
u.username, u.id AS userId
FROM decks d LEFT JOIN users u ON d.user_id = u.id
ORDER BY d.created_at DESC LIMIT 100
`).all();
res.json({
decks: rows.map((row) => ({
...row,
preview_url: getPreviewUrl(row.shareCode, row.previewImage),
})),
});
});
router.delete('/decks/:shareCode', (req, res) => {
const row = db.prepare('SELECT id FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
});
return router;
}

212
api/src/routes/auth.js Normal file
View File

@@ -0,0 +1,212 @@
import { Router } from 'express';
import path from 'path';
import bcrypt from 'bcryptjs';
import { signToken, requireAuth } from '../middleware/auth.js';
import { nowIso } from '../utils.js';
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
* DB 사용자 행을 API 응답 형식으로 변환
* @param {object} user
* @returns {object}
*/
function toUserResponse(user) {
if (!user) return null;
return {
...user,
displayName: user.username,
avatarUrl: getAvatarUrl(user.id) || user.avatarUrl || null,
defaultDeckPublic: (user.default_deck_public ?? user.defaultDeckPublic ?? 1) !== 0,
skipDeckPublicPrompt: Boolean(user.skip_deck_public_prompt ?? user.skipDeckPublicPrompt),
};
}
/**
* 인증 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createAuthRouter(db) {
const router = Router();
router.get('/avatar/:userId', (req, res) => {
const filePath = getAvatarFilePath(req.params.userId);
if (!filePath) {
return res.status(404).json({ error: '아바타가 없습니다.' });
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(path.resolve(filePath), (err) => {
if (err && !res.headersSent) {
res.status(404).json({ error: '아바타가 없습니다.' });
}
});
});
router.post('/register', (req, res) => {
const { email, username, password } = req.body || {};
if (!email || !username || !password) {
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
}
if (password.length < 8) {
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
}
const hash = bcrypt.hashSync(password, 12);
try {
const trimmedUsername = username.trim();
const result = db.prepare(`
INSERT INTO users (email, username, password_hash, display_name)
VALUES (?, ?, ?, ?)
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername);
const user = db.prepare(`
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(result.lastInsertRowid);
const safeUser = toUserResponse(user);
const token = signToken(safeUser);
res.status(201).json({ token, user: safeUser });
} catch (err) {
if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
}
throw err;
}
});
router.post('/login', (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
}
const user = db.prepare(`
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active
FROM users WHERE email = ?
`).get(email.trim().toLowerCase());
if (!user || !user.is_active) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
}
const row = db.prepare(`
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(user.id);
const safeUser = toUserResponse(row);
res.json({ token: signToken(safeUser), user: safeUser });
});
router.get('/me', requireAuth, (req, res) => {
const user = db.prepare(`
SELECT id, email, username, avatar_url AS avatarUrl, role,
default_deck_public AS defaultDeckPublic,
skip_deck_public_prompt AS skipDeckPublicPrompt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ? AND is_active = 1
`).get(req.user.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
res.json({ user: toUserResponse(user) });
});
router.put('/me', requireAuth, (req, res) => {
const { email, username, avatarImage, removeAvatar, defaultDeckPublic, skipDeckPublicPrompt } =
req.body || {};
const fields = ['updated_at = ?'];
const values = [nowIso()];
const trimmedEmail = email?.trim().toLowerCase();
const trimmedUsername = username?.trim();
if (trimmedEmail) {
fields.push('email = ?');
values.push(trimmedEmail);
}
if (trimmedUsername) {
fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername);
}
if (avatarImage) {
const saved = saveAvatarImage(req.user.id, avatarImage);
if (!saved) {
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
}
const avatarUrl = getAvatarUrl(req.user.id);
fields.push('avatar_url = ?');
values.push(avatarUrl);
} else if (removeAvatar === true) {
deleteAvatarImage(req.user.id);
fields.push('avatar_url = ?');
values.push(null);
}
if (typeof defaultDeckPublic === 'boolean') {
fields.push('default_deck_public = ?');
values.push(defaultDeckPublic ? 1 : 0);
}
if (typeof skipDeckPublicPrompt === 'boolean') {
fields.push('skip_deck_public_prompt = ?');
values.push(skipDeckPublicPrompt ? 1 : 0);
}
try {
values.push(req.user.id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
} catch (err) {
if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
}
throw err;
}
const user = db.prepare(`
SELECT id, email, username, avatar_url AS avatarUrl, role,
default_deck_public AS defaultDeckPublic,
skip_deck_public_prompt AS skipDeckPublicPrompt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.user.id);
res.json({ user: toUserResponse(user) });
});
router.post('/me/password', requireAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: '현재 비밀번호와 새 비밀번호를 입력하세요.' });
}
if (newPassword.length < 8) {
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
}
const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(currentPassword, row.password_hash)) {
return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다.' });
}
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
hash,
nowIso(),
req.user.id,
);
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
});
return router;
}

290
api/src/routes/decks.js Normal file
View File

@@ -0,0 +1,290 @@
import { Router } from 'express';
import path from 'path';
import { requireAuth, optionalAuth } from '../middleware/auth.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { generateShareCode, validateCards, nowIso } from '../utils.js';
import { savePreviewImage, getPreviewFilePath, getPreviewUrl } from '../services/previewImage.js';
/**
* 덱 행을 API 응답 형식으로 변환
* @param {object} row
* @param {object|null} author
* @param {object[]} resolvedCards
* @param {number} totalCards
* @returns {object}
*/
function toDeckResponse(row, author, resolvedCards, totalCards) {
return {
v: 1,
shareCode: row.share_code,
shareType: row.share_type,
series: row.series,
seriesName: row.series_name,
name: row.name,
totalCards,
cards: resolvedCards,
author,
createdAt: row.created_at,
updatedAt: row.updated_at,
viewCount: row.view_count,
isPublic: Boolean(row.is_public),
expiresAt: row.expires_at,
previewUrl: getPreviewUrl(row.share_code, row.preview_image),
};
}
/**
* 작성자 정보 조회
* @param {import('better-sqlite3').Database} db
* @param {number|null} userId
* @returns {object|null}
*/
function getAuthor(db, userId) {
if (!userId) return null;
const user = db.prepare(`
SELECT id, username, avatar_url AS avatarUrl
FROM users WHERE id = ? AND is_active = 1
`).get(userId);
if (!user) return null;
return { ...user, displayName: user.username };
}
/**
* 덱 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createDecksRouter(db) {
const router = Router();
router.get('/presets', async (_req, res, next) => {
try {
const rows = db.prepare(`
SELECT d.share_code, d.series, d.series_name, d.name, d.view_count,
d.preview_image, d.created_at, d.updated_at,
u.display_name, u.username
FROM decks d
LEFT JOIN users u ON d.user_id = u.id
WHERE d.share_type = 'user' AND d.is_public = 1
AND (d.expires_at IS NULL OR d.expires_at > datetime('now'))
ORDER BY d.updated_at DESC
`).all();
res.json({
presets: rows.map((row) => ({
share_code: row.share_code,
series: row.series,
series_name: row.series_name,
name: row.name,
view_count: row.view_count,
author: row.username || '익명',
created_at: row.created_at,
updated_at: row.updated_at,
preview_url: getPreviewUrl(row.share_code, row.preview_image),
})),
});
} catch (err) {
next(err);
}
});
router.get('/:shareCode/preview', (req, res) => {
const filePath = getPreviewFilePath(req.params.shareCode);
if (!filePath) {
return res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(path.resolve(filePath), (err) => {
if (err && !res.headersSent) {
res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
}
});
});
router.get('/:shareCode', optionalAuth, async (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.expires_at && new Date(row.expires_at) < new Date()) {
return res.status(410).json({ error: '만료된 공유 링크입니다.' });
}
const isOwner = req.user && row.user_id === req.user.id;
const isAdmin = req.user?.role === 'admin';
if (!row.is_public && !isOwner && !isAdmin) {
return res.status(403).json({ error: '비공개 덱입니다.' });
}
const cards = JSON.parse(row.cards_json);
const { cards: resolved, totalCards } = await resolveDeckCards(row.series, cards);
const author = getAuthor(db, row.user_id);
db.prepare('UPDATE decks SET view_count = view_count + 1 WHERE id = ?').run(row.id);
row.view_count += 1;
res.json(toDeckResponse(row, author, resolved, totalCards));
} catch (err) {
next(err);
}
});
router.get('/', requireAuth, async (req, res, next) => {
try {
const rows = db.prepare(`
SELECT share_code, share_type, series, series_name, name, is_public, view_count,
preview_image, created_at, updated_at
FROM decks WHERE user_id = ? AND share_type = 'user'
ORDER BY updated_at DESC
`).all(req.user.id);
res.json({
decks: rows.map((row) => ({
share_code: row.share_code,
share_type: row.share_type,
series: row.series,
series_name: row.series_name,
name: row.name,
is_public: row.is_public,
view_count: row.view_count,
created_at: row.created_at,
updated_at: row.updated_at,
preview_url: getPreviewUrl(row.share_code, row.preview_image),
})),
});
} catch (err) {
next(err);
}
});
router.post('/', optionalAuth, async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic, previewImage } = req.body || {};
if (!series || !cards) {
return res.status(400).json({ error: 'series와 cards는 필수입니다.' });
}
const normalized = validateCards(cards);
const cardsJson = JSON.stringify(normalized);
const shareCode = generateShareCode();
const userId = req.user?.id ?? null;
const expiresAt = userId ? null : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const isPublicValue = userId ? (isPublic === true ? 1 : 0) : 1;
let previewFile = null;
if (previewImage) {
previewFile = savePreviewImage(shareCode, previewImage);
if (!previewFile) {
console.warn(`[preview] 저장 실패: ${shareCode} (dataUrl 길이 ${previewImage.length})`);
}
}
db.prepare(`
INSERT INTO decks (share_code, share_type, user_id, series, series_name, name, cards_json, is_public, expires_at, preview_image)
VALUES (?, 'user', ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
shareCode,
userId,
series,
seriesName || series,
name || '',
cardsJson,
isPublicValue,
expiresAt,
previewFile,
);
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(shareCode);
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
const author = getAuthor(db, userId);
res.status(201).json(toDeckResponse(row, author, resolved, totalCards));
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.put('/:shareCode', requireAuth, async (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '수정 권한이 없습니다.' });
}
if (row.share_type === 'preset' && req.user.role !== 'admin') {
return res.status(403).json({ error: '레거시 프리셋 덱은 관리자만 수정할 수 있습니다.' });
}
const { name, cards, isPublic, seriesName, previewImage } = req.body || {};
let cardsJson = row.cards_json;
let series = row.series;
let previewFile = row.preview_image;
if (cards) {
const normalized = validateCards(cards);
cardsJson = JSON.stringify(normalized);
}
if (req.body?.series) {
series = req.body.series;
}
if (previewImage) {
previewFile = savePreviewImage(row.share_code, previewImage);
}
db.prepare(`
UPDATE decks SET
name = COALESCE(?, name),
series_name = COALESCE(?, series_name),
series = ?,
cards_json = ?,
is_public = COALESCE(?, is_public),
preview_image = COALESCE(?, preview_image),
updated_at = ?
WHERE id = ?
`).run(
name ?? null,
seriesName ?? null,
series,
cardsJson,
isPublic === undefined ? null : (isPublic ? 1 : 0),
previewFile,
nowIso(),
row.id,
);
const updated = db.prepare('SELECT * FROM decks WHERE id = ?').get(row.id);
const parsed = JSON.parse(updated.cards_json);
const { cards: resolved, totalCards } = await resolveDeckCards(updated.series, parsed);
res.json(toDeckResponse(updated, getAuthor(db, updated.user_id), resolved, totalCards));
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.delete('/:shareCode', requireAuth, (req, res) => {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '삭제 권한이 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
});
return router;
}