Skip to content

Commit 438d481

Browse files
Sora4431claude
andcommitted
fix(user): address code review issues for #161
- Fix N+1 queries in syncBookmarks using batch INSERT OR IGNORE - Add 50-item limit validation for sync-bookmarks endpoint - Wrap addBookmark in try/catch to prevent false 500 on itinerary creation - Revert client.ts user-token fallback; use explicit postWithUserToken instead - Filter syncLocalBookmarks to only owned itineraries (token !== null) - Add integration tests for sync-bookmarks and auto-bookmark on create Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6a0b022 commit 438d481

7 files changed

Lines changed: 273 additions & 46 deletions

File tree

apps/api/src/routes/itineraries.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ itineraries.post('/', optionalUserAuthMiddleware, async (c) => {
3636

3737
const userId = c.get('userId');
3838
if (userId) {
39-
const userService = new UserService(c.env.DB);
40-
await userService.addBookmark(userId, data.id);
39+
try {
40+
const userService = new UserService(c.env.DB);
41+
await userService.addBookmark(userId, data.id);
42+
} catch {
43+
// 非致命的: しおりは作成済み、/me/sync-bookmarks で後から同期可能
44+
}
4145
}
4246

4347
return c.json({ success: true, data: { ...response, token } }, 201);

apps/api/src/routes/users.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ users.post('/me/sync-bookmarks', userAuthMiddleware, async (c) => {
8080
}, 400);
8181
}
8282

83+
if (input.itinerary_ids.length > 50) {
84+
return c.json({
85+
success: false,
86+
error: { code: 'INVALID_INPUT', message: 'Too many itinerary_ids (max 50)' }
87+
}, 400);
88+
}
89+
8390
const service = new UserService(c.env.DB);
8491
const result = await service.syncBookmarks(userId, input.itinerary_ids);
8592
return c.json({ success: true, data: result });

apps/api/src/services/user.service.ts

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -154,46 +154,34 @@ export class UserService {
154154
}
155155

156156
// ログイン時の localStorage→server 同期
157-
// 存在する itinerary_id のみ user_bookmarks に追加、既存はスキップ
157+
// 存在する itinerary_id のみ user_bookmarks に追加、既存はスキップ(INSERT OR IGNORE で重複排除)
158158
async syncBookmarks(userId: string, itineraryIds: string[]): Promise<SyncBookmarksResponse> {
159-
let synced = 0;
160-
let skipped = 0;
161-
162-
for (const itineraryId of itineraryIds) {
163-
// itinerary が存在するか確認
164-
const itinerary = await this.db
165-
.prepare('SELECT id FROM itineraries WHERE id = ?')
166-
.bind(itineraryId)
167-
.first();
159+
if (itineraryIds.length === 0) return { synced: 0, skipped: 0 };
168160

169-
if (!itinerary) {
170-
skipped++;
171-
continue;
172-
}
161+
// 1. 存在する itinerary を一括確認
162+
const placeholders = itineraryIds.map(() => '?').join(', ');
163+
const existing = await this.db
164+
.prepare(`SELECT id FROM itineraries WHERE id IN (${placeholders})`)
165+
.bind(...itineraryIds)
166+
.all<{ id: string }>();
173167

174-
// 既に紐付け済みか確認
175-
const existing = await this.db
176-
.prepare('SELECT user_id FROM user_bookmarks WHERE user_id = ? AND itinerary_id = ?')
177-
.bind(userId, itineraryId)
178-
.first();
168+
const validIds = (existing.results ?? []).map(r => r.id);
169+
const skipped = itineraryIds.length - validIds.length;
179170

180-
if (existing) {
181-
skipped++;
182-
continue;
183-
}
171+
if (validIds.length === 0) return { synced: 0, skipped };
184172

185-
const now = getCurrentTimestamp();
186-
await this.db
187-
.prepare(
188-
'INSERT INTO user_bookmarks (user_id, itinerary_id, is_visible, created_at, updated_at) VALUES (?, ?, 1, ?, ?)'
189-
)
190-
.bind(userId, itineraryId, now, now)
191-
.run();
173+
// 2. INSERT OR IGNORE で一括追加(複合主キーの重複は自動スキップ)
174+
const now = getCurrentTimestamp();
175+
const stmts = validIds.map(id =>
176+
this.db
177+
.prepare('INSERT OR IGNORE INTO user_bookmarks (user_id, itinerary_id, is_visible, created_at, updated_at) VALUES (?, ?, 1, ?, ?)')
178+
.bind(userId, id, now, now)
179+
);
180+
const results = await this.db.batch(stmts);
192181

193-
synced++;
194-
}
182+
const synced = results.filter(r => r.meta?.changes && r.meta.changes > 0).length;
195183

196-
return { synced, skipped };
184+
return { synced, skipped: skipped + (validIds.length - synced) };
197185
}
198186

199187
async addBookmark(userId: string, itineraryId: string): Promise<UserBookmark> {

apps/api/test/users-sync.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { env } from 'cloudflare:test';
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import app from '../src/index';
4+
5+
async function applyMigrations(db: D1Database) {
6+
const migrations = [
7+
`CREATE TABLE IF NOT EXISTS itineraries (
8+
id TEXT PRIMARY KEY,
9+
title TEXT NOT NULL,
10+
theme_id TEXT NOT NULL DEFAULT 'standard-autumn',
11+
memo TEXT,
12+
password TEXT,
13+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
15+
);`,
16+
`CREATE TABLE IF NOT EXISTS steps (
17+
id TEXT PRIMARY KEY,
18+
itinerary_id TEXT NOT NULL,
19+
title TEXT NOT NULL,
20+
start_at INTEGER NOT NULL,
21+
end_at INTEGER NOT NULL,
22+
location TEXT,
23+
notes TEXT,
24+
type TEXT NOT NULL DEFAULT 'normal:general',
25+
is_all_day INTEGER NOT NULL DEFAULT 0,
26+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
27+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
28+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
29+
);`,
30+
`CREATE INDEX IF NOT EXISTS idx_steps_itinerary ON steps(itinerary_id);`,
31+
`CREATE TABLE IF NOT EXISTS itinerary_secrets (
32+
itinerary_id TEXT PRIMARY KEY,
33+
enabled BOOLEAN DEFAULT FALSE,
34+
offset_minutes INTEGER DEFAULT 60,
35+
created_at TEXT NOT NULL,
36+
updated_at TEXT NOT NULL,
37+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
38+
);`,
39+
`CREATE TABLE IF NOT EXISTS itinerary_walica_settings (
40+
itinerary_id TEXT PRIMARY KEY,
41+
walica_id TEXT NOT NULL,
42+
created_at TEXT NOT NULL,
43+
updated_at TEXT NOT NULL,
44+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
45+
);`,
46+
`CREATE TABLE IF NOT EXISTS users (
47+
id TEXT PRIMARY KEY,
48+
username TEXT UNIQUE NOT NULL,
49+
email TEXT UNIQUE NOT NULL,
50+
password_hash TEXT NOT NULL,
51+
created_at TEXT NOT NULL,
52+
updated_at TEXT NOT NULL
53+
);`,
54+
`CREATE TABLE IF NOT EXISTS user_bookmarks (
55+
user_id TEXT NOT NULL,
56+
itinerary_id TEXT NOT NULL,
57+
is_visible BOOLEAN NOT NULL DEFAULT 1,
58+
created_at TEXT NOT NULL,
59+
updated_at TEXT NOT NULL,
60+
PRIMARY KEY (user_id, itinerary_id),
61+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
62+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
63+
);`,
64+
];
65+
for (const sql of migrations) {
66+
await db.prepare(sql).run();
67+
}
68+
}
69+
70+
async function registerAndGetToken(username: string, email: string): Promise<string> {
71+
const res = await app.request('/api/v1/users/register', {
72+
method: 'POST',
73+
headers: { 'Content-Type': 'application/json' },
74+
body: JSON.stringify({ username, email, password: 'password123' }),
75+
}, env);
76+
const json = await res.json() as { success: boolean; data: { token: string } };
77+
return json.data.token;
78+
}
79+
80+
async function createItinerary(): Promise<string> {
81+
const res = await app.request('/api/v1/itineraries', {
82+
method: 'POST',
83+
headers: { 'Content-Type': 'application/json' },
84+
body: JSON.stringify({ title: 'テスト旅程' }),
85+
}, env);
86+
const json = await res.json() as { success: boolean; data: { id: string } };
87+
return json.data.id;
88+
}
89+
90+
describe('POST /api/v1/users/me/sync-bookmarks', () => {
91+
beforeEach(async () => {
92+
await applyMigrations(env.DB);
93+
await env.DB.prepare('DELETE FROM user_bookmarks').run();
94+
await env.DB.prepare('DELETE FROM users').run();
95+
await env.DB.prepare('DELETE FROM itineraries').run();
96+
});
97+
98+
it('returns 401 without auth token', async () => {
99+
const res = await app.request('/api/v1/users/me/sync-bookmarks', {
100+
method: 'POST',
101+
headers: { 'Content-Type': 'application/json' },
102+
body: JSON.stringify({ itinerary_ids: [] }),
103+
}, env);
104+
expect(res.status).toBe(401);
105+
});
106+
107+
it('syncs valid itinerary IDs and returns correct counts', async () => {
108+
const token = await registerAndGetToken('testuser', 'test@example.com');
109+
const id1 = await createItinerary();
110+
const id2 = await createItinerary();
111+
112+
const res = await app.request('/api/v1/users/me/sync-bookmarks', {
113+
method: 'POST',
114+
headers: {
115+
'Content-Type': 'application/json',
116+
Authorization: `Bearer ${token}`,
117+
},
118+
body: JSON.stringify({ itinerary_ids: [id1, id2] }),
119+
}, env);
120+
121+
expect(res.status).toBe(200);
122+
const json = await res.json() as { success: boolean; data: { synced: number; skipped: number } };
123+
expect(json.success).toBe(true);
124+
expect(json.data.synced).toBe(2);
125+
expect(json.data.skipped).toBe(0);
126+
});
127+
128+
it('skips non-existent itinerary IDs', async () => {
129+
const token = await registerAndGetToken('testuser2', 'test2@example.com');
130+
131+
const res = await app.request('/api/v1/users/me/sync-bookmarks', {
132+
method: 'POST',
133+
headers: {
134+
'Content-Type': 'application/json',
135+
Authorization: `Bearer ${token}`,
136+
},
137+
body: JSON.stringify({ itinerary_ids: ['nonexistent-id'] }),
138+
}, env);
139+
140+
expect(res.status).toBe(200);
141+
const json = await res.json() as { success: boolean; data: { synced: number; skipped: number } };
142+
expect(json.data.synced).toBe(0);
143+
expect(json.data.skipped).toBe(1);
144+
});
145+
146+
it('skips already-bookmarked IDs on second call', async () => {
147+
const token = await registerAndGetToken('testuser3', 'test3@example.com');
148+
const id = await createItinerary();
149+
150+
await app.request('/api/v1/users/me/sync-bookmarks', {
151+
method: 'POST',
152+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
153+
body: JSON.stringify({ itinerary_ids: [id] }),
154+
}, env);
155+
156+
const res = await app.request('/api/v1/users/me/sync-bookmarks', {
157+
method: 'POST',
158+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
159+
body: JSON.stringify({ itinerary_ids: [id] }),
160+
}, env);
161+
162+
expect(res.status).toBe(200);
163+
const json = await res.json() as { success: boolean; data: { synced: number; skipped: number } };
164+
expect(json.data.synced).toBe(0);
165+
expect(json.data.skipped).toBe(1);
166+
});
167+
168+
it('returns 400 for more than 50 IDs', async () => {
169+
const token = await registerAndGetToken('testuser4', 'test4@example.com');
170+
const ids = Array.from({ length: 51 }, (_, i) => `id-${i}`);
171+
172+
const res = await app.request('/api/v1/users/me/sync-bookmarks', {
173+
method: 'POST',
174+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
175+
body: JSON.stringify({ itinerary_ids: ids }),
176+
}, env);
177+
178+
expect(res.status).toBe(400);
179+
});
180+
});
181+
182+
describe('POST /api/v1/itineraries with user token', () => {
183+
beforeEach(async () => {
184+
await applyMigrations(env.DB);
185+
await env.DB.prepare('DELETE FROM user_bookmarks').run();
186+
await env.DB.prepare('DELETE FROM users').run();
187+
await env.DB.prepare('DELETE FROM itineraries').run();
188+
});
189+
190+
it('creates user_bookmark record when creating itinerary while logged in', async () => {
191+
const token = await registerAndGetToken('creator', 'creator@example.com');
192+
193+
const res = await app.request('/api/v1/itineraries', {
194+
method: 'POST',
195+
headers: {
196+
'Content-Type': 'application/json',
197+
Authorization: `Bearer ${token}`,
198+
},
199+
body: JSON.stringify({ title: 'マイ旅程' }),
200+
}, env);
201+
202+
expect(res.status).toBe(201);
203+
const json = await res.json() as { success: boolean; data: { id: string } };
204+
const itineraryId = json.data.id;
205+
206+
const bookmarks = await app.request('/api/v1/users/me/bookmarks', {
207+
headers: { Authorization: `Bearer ${token}` },
208+
}, env);
209+
const bJson = await bookmarks.json() as { success: boolean; data: { bookmarks: { itinerary_id: string }[] } };
210+
expect(bJson.data.bookmarks.some(b => b.itinerary_id === itineraryId)).toBe(true);
211+
});
212+
213+
it('creates itinerary normally without user token (no bookmark)', async () => {
214+
const res = await app.request('/api/v1/itineraries', {
215+
method: 'POST',
216+
headers: { 'Content-Type': 'application/json' },
217+
body: JSON.stringify({ title: '匿名旅程' }),
218+
}, env);
219+
expect(res.status).toBe(201);
220+
});
221+
});

apps/web/src/lib/api/client.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { ApiResult } from '@tabitabi/types';
22
import { auth } from '../auth';
3-
import { userAuth } from '../user-auth';
43
import { getIsDemoMode } from '../demo';
54

65
// Prefer PUBLIC_ for Cloudflare Pages, fallback to VITE_
@@ -25,16 +24,9 @@ export class ApiClient {
2524
const token = auth.getToken(shioriId);
2625
if (token) {
2726
headers['Authorization'] = `Bearer ${token}`;
28-
return headers;
2927
}
3028
}
3129

32-
// shiori トークンがない場合はユーザートークンを使用(ログイン済み時のしおり作成など)
33-
const userToken = userAuth.getToken();
34-
if (userToken) {
35-
headers['Authorization'] = `Bearer ${userToken}`;
36-
}
37-
3830
return headers;
3931
}
4032

@@ -78,6 +70,15 @@ export class ApiClient {
7870
}, shioriId);
7971
}
8072

73+
// ユーザートークンを明示的に使用する POST(しおり作成時のユーザー紐付けなど)
74+
async postWithUserToken<T>(endpoint: string, data: unknown, userToken: string): Promise<T> {
75+
return this.request<T>(endpoint, {
76+
method: 'POST',
77+
body: JSON.stringify(data),
78+
headers: { Authorization: `Bearer ${userToken}` },
79+
});
80+
}
81+
8182
async put<T>(endpoint: string, data: unknown, shioriId?: string): Promise<T> {
8283
return this.request<T>(endpoint, {
8384
method: 'PUT',

apps/web/src/lib/api/itinerary.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import type { Itinerary, CreateItineraryInput, UpdateItineraryInput, ItineraryResponse } from '@tabitabi/types';
22
import { apiClient } from './client';
3+
import { userAuth } from '../user-auth';
34

45
export const itineraryApi = {
56
list: () => apiClient.get<ItineraryResponse[]>('/itineraries'),
67

78
get: (id: string) => apiClient.get<ItineraryResponse>(`/itineraries/${id}`),
89

9-
create: (data: CreateItineraryInput) =>
10-
apiClient.post<ItineraryResponse & { token: string }>('/itineraries', data),
10+
create: (data: CreateItineraryInput) => {
11+
const userToken = userAuth.getToken();
12+
if (userToken) {
13+
return apiClient.postWithUserToken<ItineraryResponse & { token: string }>('/itineraries', data, userToken);
14+
}
15+
return apiClient.post<ItineraryResponse & { token: string }>('/itineraries', data);
16+
},
1117

1218
update: (id: string, data: UpdateItineraryInput) =>
1319
apiClient.put<ItineraryResponse>(`/itineraries/${id}`, data, id),

apps/web/src/routes/profile/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
async function syncLocalBookmarks() {
2424
const history = auth.getHistory();
2525
const ids = history
26-
.filter((h) => h.shioriId !== "demo")
26+
.filter((h) => h.shioriId !== "demo" && h.token !== null)
2727
.map((h) => h.shioriId);
2828
if (ids.length === 0) return;
2929
try {

0 commit comments

Comments
 (0)