Skip to content

Commit fef03b5

Browse files
committed
fix(itinerary): address code review issues for SOR-11
- Use db.batch() for atomic fork operation (itinerary + steps + fork_count) - Merge duplicate onMount into single callback, use userAuth.isLoggedIn() - Add null guard before postWithUserToken in fork() API client - Make fork_count required (non-optional) in Itinerary type - Consolidate fork test migrations into shared applyMigrations helper - Use explicit column list in steps SELECT for robustness - Add fork_count=0 to create() Itinerary object literal
1 parent 3e750a8 commit fef03b5

5 files changed

Lines changed: 45 additions & 90 deletions

File tree

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

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export class ItineraryService {
6565
enabled: input.secret_settings.enabled,
6666
offset_minutes: input.secret_settings.offset_minutes
6767
} : null,
68+
fork_count: 0,
6869
created_at: now,
6970
updated_at: now,
7071
};
@@ -202,29 +203,31 @@ export class ItineraryService {
202203
const newId = generateId();
203204
const now = getCurrentTimestamp();
204205

205-
await this.db
206-
.prepare('INSERT INTO itineraries (id, title, theme_id, memo, password, fork_count, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, 0, ?, ?)')
207-
.bind(newId, `${source.title}(コピー)`, source.theme_id, source.memo, now, now)
208-
.run();
209-
206+
// Fetch source steps before batch to generate new IDs
207+
// secret_settings and walica_id are intentionally excluded from forks (personal configuration)
210208
const sourceSteps = await this.db
211-
.prepare('SELECT * FROM steps WHERE itinerary_id = ? ORDER BY start_at ASC')
209+
.prepare('SELECT id, itinerary_id, title, start_at, end_at, location, notes, type, is_all_day FROM steps WHERE itinerary_id = ? ORDER BY start_at ASC')
212210
.bind(sourceId)
213211
.all();
214212

215213
const rows = sourceSteps.results ?? [];
216-
for (const row of rows) {
217-
const stepId = generateId();
218-
await this.db
219-
.prepare('INSERT INTO steps (id, itinerary_id, title, start_at, end_at, location, notes, type, is_all_day, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
220-
.bind(stepId, newId, row.title, row.start_at, row.end_at, row.location, row.notes, row.type, row.is_all_day, now, now)
221-
.run();
222-
}
223214

224-
await this.db
225-
.prepare('UPDATE itineraries SET fork_count = fork_count + 1, updated_at = ? WHERE id = ?')
226-
.bind(now, sourceId)
227-
.run();
215+
// Use batch() for atomic execution: all inserts + fork_count increment succeed or fail together
216+
const stepStatements = rows.map(row =>
217+
this.db
218+
.prepare('INSERT INTO steps (id, itinerary_id, title, start_at, end_at, location, notes, type, is_all_day, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
219+
.bind(generateId(), newId, row.title, row.start_at, row.end_at, row.location, row.notes, row.type, row.is_all_day, now, now)
220+
);
221+
222+
await this.db.batch([
223+
this.db
224+
.prepare('INSERT INTO itineraries (id, title, theme_id, memo, password, fork_count, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, 0, ?, ?)')
225+
.bind(newId, `${source.title}(コピー)`, source.theme_id, source.memo, now, now),
226+
...stepStatements,
227+
this.db
228+
.prepare('UPDATE itineraries SET fork_count = fork_count + 1, updated_at = ? WHERE id = ?')
229+
.bind(now, sourceId),
230+
]);
228231

229232
const forked = await this.get(newId);
230233
return { itinerary: forked!, steps: rows.length };

apps/api/test/itineraries.test.ts

Lines changed: 19 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ async function applyMigrations(db: D1Database) {
4444
updated_at TEXT NOT NULL,
4545
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
4646
);`,
47+
`CREATE TABLE IF NOT EXISTS users (
48+
id TEXT PRIMARY KEY,
49+
username TEXT UNIQUE NOT NULL,
50+
email TEXT UNIQUE NOT NULL,
51+
password_hash TEXT NOT NULL,
52+
created_at TEXT NOT NULL,
53+
updated_at TEXT NOT NULL
54+
);`,
55+
`CREATE TABLE IF NOT EXISTS user_bookmarks (
56+
user_id TEXT NOT NULL,
57+
itinerary_id TEXT NOT NULL,
58+
is_visible BOOLEAN NOT NULL DEFAULT 1,
59+
created_at TEXT NOT NULL,
60+
updated_at TEXT NOT NULL,
61+
PRIMARY KEY (user_id, itinerary_id),
62+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
63+
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
64+
);`,
4765
];
4866

4967
for (const sql of migrations) {
@@ -241,72 +259,6 @@ describe('Itineraries API', () => {
241259
});
242260

243261
describe('POST /api/v1/itineraries/:id/fork', () => {
244-
async function applyForkMigrations(db: D1Database) {
245-
const migrations = [
246-
`CREATE TABLE IF NOT EXISTS itineraries (
247-
id TEXT PRIMARY KEY,
248-
title TEXT NOT NULL,
249-
theme_id TEXT NOT NULL DEFAULT 'standard-autumn',
250-
memo TEXT,
251-
password TEXT,
252-
fork_count INTEGER NOT NULL DEFAULT 0,
253-
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
254-
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
255-
);`,
256-
`CREATE TABLE IF NOT EXISTS steps (
257-
id TEXT PRIMARY KEY,
258-
itinerary_id TEXT NOT NULL,
259-
title TEXT NOT NULL,
260-
start_at INTEGER NOT NULL,
261-
end_at INTEGER NOT NULL,
262-
location TEXT,
263-
notes TEXT,
264-
type TEXT NOT NULL DEFAULT 'normal:general',
265-
is_all_day INTEGER NOT NULL DEFAULT 0,
266-
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
267-
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
268-
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
269-
);`,
270-
`CREATE INDEX IF NOT EXISTS idx_steps_itinerary ON steps(itinerary_id);`,
271-
`CREATE TABLE IF NOT EXISTS itinerary_secrets (
272-
itinerary_id TEXT PRIMARY KEY,
273-
enabled BOOLEAN DEFAULT FALSE,
274-
offset_minutes INTEGER DEFAULT 60,
275-
created_at TEXT NOT NULL,
276-
updated_at TEXT NOT NULL,
277-
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
278-
);`,
279-
`CREATE TABLE IF NOT EXISTS itinerary_walica_settings (
280-
itinerary_id TEXT PRIMARY KEY,
281-
walica_id TEXT NOT NULL,
282-
created_at TEXT NOT NULL,
283-
updated_at TEXT NOT NULL,
284-
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
285-
);`,
286-
`CREATE TABLE IF NOT EXISTS users (
287-
id TEXT PRIMARY KEY,
288-
username TEXT UNIQUE NOT NULL,
289-
email TEXT UNIQUE NOT NULL,
290-
password_hash TEXT NOT NULL,
291-
created_at TEXT NOT NULL,
292-
updated_at TEXT NOT NULL
293-
);`,
294-
`CREATE TABLE IF NOT EXISTS user_bookmarks (
295-
user_id TEXT NOT NULL,
296-
itinerary_id TEXT NOT NULL,
297-
is_visible BOOLEAN NOT NULL DEFAULT 1,
298-
created_at TEXT NOT NULL,
299-
updated_at TEXT NOT NULL,
300-
PRIMARY KEY (user_id, itinerary_id),
301-
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
302-
FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE
303-
);`,
304-
];
305-
for (const sql of migrations) {
306-
await db.prepare(sql).run();
307-
}
308-
}
309-
310262
async function registerAndGetToken(username: string, email: string): Promise<string> {
311263
const res = await app.request('/api/v1/users/register', {
312264
method: 'POST',
@@ -318,7 +270,7 @@ describe('POST /api/v1/itineraries/:id/fork', () => {
318270
}
319271

320272
beforeEach(async () => {
321-
await applyForkMigrations(env.DB);
273+
await applyMigrations(env.DB);
322274
await env.DB.prepare('DELETE FROM user_bookmarks').run();
323275
await env.DB.prepare('DELETE FROM steps').run();
324276
await env.DB.prepare('DELETE FROM itinerary_secrets').run();

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const itineraryApi = {
2222

2323
fork: (id: string) => {
2424
const userToken = userAuth.getToken();
25-
return apiClient.postWithUserToken<ForkItineraryResponse>(`/itineraries/${id}/fork`, {}, userToken!);
25+
if (!userToken) throw new Error('Not logged in');
26+
return apiClient.postWithUserToken<ForkItineraryResponse>(`/itineraries/${id}/fork`, {}, userToken);
2627
},
2728
};

apps/web/src/routes/[id]/+page.svelte

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
}
9090
};
9191
92+
isLoggedIn = userAuth.isLoggedIn();
9293
init();
9394
9495
return () => {
@@ -180,11 +181,9 @@
180181
let isLoggedIn = $state(false);
181182
let forking = $state(false);
182183
183-
onMount(() => {
184-
isLoggedIn = !!userAuth.getToken();
185-
});
186-
187184
async function handleFork() {
185+
const userToken = userAuth.getToken();
186+
if (!userToken) return;
188187
forking = true;
189188
try {
190189
const result = await itineraryApi.fork(data.itinerary.id);

packages/types/src/itinerary.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export interface Itinerary {
1111
walica_id?: string | null;
1212
password?: string | null;
1313
secret_settings?: ItinerarySecretSettings | null;
14-
fork_count?: number;
14+
fork_count: number;
1515
created_at: string;
1616
updated_at: string;
1717
}

0 commit comments

Comments
 (0)