Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions apps/api/src/routes/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,44 @@ steps.get('/', async (c) => {
}, 400);
}

const service = new StepService(c.env.DB);
const data = await service.list(itineraryId);
// Check if user is authenticated (edit mode)
const authHeader = c.req.header('Authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

let isEditMode = false;
if (token) {
const { verifyToken } = await import('../utils/jwt');
const payload = await verifyToken(token, c.env.JWT_SECRET);
isEditMode = !!payload;
}

const stepService = new StepService(c.env.DB);
const itineraryService = new ItineraryService(c.env.DB);
const itinerary = await itineraryService.get(itineraryId);

if (itinerary?.secret_settings?.enabled) {
// Get current time in JST (UTC+9) because DB stores local time
const now = new Date();
const jstOffset = 9 * 60 * 60 * 1000;
const jstTime = new Date(now.getTime() + jstOffset).toISOString().replace('Z', '');

const offsetMinutes = itinerary.secret_settings.offset_minutes || 60;

// Filter steps based on time
// If isEditMode is true OR itinerary has no password, we DO NOT mask secrets (maskSecrets: false)
// But we still pass time/offset so is_hidden is calculated
const hasEditPermission = isEditMode || !itinerary.password;

const data = await stepService.list(itineraryId, {
currentTime: jstTime,
offsetMinutes: offsetMinutes,
maskSecrets: !hasEditPermission
});
return c.json({ success: true, data });
}

// Secret mode disabled: return all steps
const data = await stepService.list(itineraryId);
return c.json({ success: true, data });
});

Expand Down
81 changes: 77 additions & 4 deletions apps/api/src/services/itinerary.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,25 @@ export class ItineraryService {

async list(): Promise<Itinerary[]> {
const result = await this.db
.prepare('SELECT * FROM itineraries ORDER BY created_at DESC')
.prepare(`
SELECT i.*, s.enabled as secret_enabled, s.offset_minutes as secret_offset
FROM itineraries i
LEFT JOIN itinerary_secrets s ON i.id = s.itinerary_id
ORDER BY i.created_at DESC
`)
.all();

return result.results ? result.results.map(row => this.mapToItinerary(row)) : [];
}

async get(id: string): Promise<Itinerary | null> {
const result = await this.db
.prepare('SELECT * FROM itineraries WHERE id = ?')
.prepare(`
SELECT i.*, s.enabled as secret_enabled, s.offset_minutes as secret_offset
FROM itineraries i
LEFT JOIN itinerary_secrets s ON i.id = s.itinerary_id
WHERE i.id = ?
`)
.bind(id)
.first();

Expand All @@ -32,15 +42,34 @@ export class ItineraryService {
theme_id: input.theme_id || 'minimal',
memo: input.memo ?? null,
password: input.password ?? null,
secret_settings: input.secret_settings ? {
enabled: input.secret_settings.enabled,
offset_minutes: input.secret_settings.offset_minutes
} : null,
created_at: now,
updated_at: now,
};

// Insert into main table
await this.db
.prepare('INSERT INTO itineraries (id, title, theme_id, memo, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.bind(itinerary.id, itinerary.title, itinerary.theme_id, itinerary.memo, itinerary.password, itinerary.created_at, itinerary.updated_at)
.run();

// Insert into secrets table if settings exist
if (itinerary.secret_settings) {
await this.db
.prepare('INSERT INTO itinerary_secrets (itinerary_id, enabled, offset_minutes, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
.bind(
itinerary.id,
itinerary.secret_settings.enabled ? 1 : 0,
itinerary.secret_settings.offset_minutes,
now,
now
)
.run();
}

return itinerary;
}

Expand All @@ -50,7 +79,7 @@ export class ItineraryService {

const now = getCurrentTimestamp();
const fields = ['updated_at = ?'];
const values = [now];
const values: any[] = [now];

if (input.title !== undefined) {
fields.push('title = ?');
Expand All @@ -77,10 +106,45 @@ export class ItineraryService {
.run();
}

// Handle secret settings update
if (input.secret_settings !== undefined) {
if (input.secret_settings === null) {
// Remove settings
await this.db
.prepare('DELETE FROM itinerary_secrets WHERE itinerary_id = ?')
.bind(id)
.run();
} else {
// Upsert settings
// Check if exists first (D1 doesn't support INSERT OR REPLACE nicely with timestamps preservation if we want that, but here we just overwrite)
// Actually, standard SQL UPSERT or just DELETE+INSERT or UPDATE/INSERT check.
// Let's try INSERT OR REPLACE
await this.db
.prepare(`
INSERT INTO itinerary_secrets (itinerary_id, enabled, offset_minutes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(itinerary_id) DO UPDATE SET
enabled = excluded.enabled,
offset_minutes = excluded.offset_minutes,
updated_at = excluded.updated_at
`)
.bind(
id,
input.secret_settings.enabled ? 1 : 0,
input.secret_settings.offset_minutes,
now,
now
)
.run();
}
}

return await this.get(id);
}

async delete(id: string): Promise<boolean> {
// Foreign key cascade should handle the secrets table, but let's be safe or rely on DB
// Since we defined ON DELETE CASCADE in migration, deleting from itineraries is enough.
const result = await this.db
.prepare('DELETE FROM itineraries WHERE id = ?')
.bind(id)
Expand All @@ -90,7 +154,7 @@ export class ItineraryService {
}

private mapToItinerary(row: any): Itinerary {
return {
const itinerary: Itinerary = {
id: row.id,
title: row.title,
theme_id: row.theme_id,
Expand All @@ -99,5 +163,14 @@ export class ItineraryService {
created_at: row.created_at,
updated_at: row.updated_at,
};

if (row.secret_enabled !== null && row.secret_enabled !== undefined) {
itinerary.secret_settings = {
enabled: row.secret_enabled === 1,
offset_minutes: row.secret_offset
};
}

return itinerary;
}
}
44 changes: 38 additions & 6 deletions apps/api/src/services/step.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,36 @@ import { generateId, getCurrentTimestamp } from '../utils';
export class StepService {
constructor(private db: D1Database) {}

async list(itineraryId: string): Promise<Step[]> {
async list(itineraryId: string, options?: { currentTime?: string; offsetMinutes?: number; maskSecrets?: boolean }): Promise<Step[]> {
let query = 'SELECT * FROM steps WHERE itinerary_id = ?';
const bindings: any[] = [itineraryId];

// If time filtering is enabled (secret mode)
if (options?.currentTime && options?.offsetMinutes !== undefined) {
// Calculate is_hidden_flag: 1 if (step_time > current_time + offset), else 0
// We select all steps but mark them as hidden if they are in the future beyond the offset
query = `
SELECT *,
(datetime(date || ' ' || time) > datetime(?, '+' || ? || ' minutes')) as is_hidden_flag
FROM steps
WHERE itinerary_id = ?
`;

// Bindings order: currentTime, offsetMinutes, itineraryId
bindings.length = 0;
bindings.push(options.currentTime);
bindings.push(options.offsetMinutes.toString());
bindings.push(itineraryId);
}

query += ' ORDER BY date ASC, time ASC';

const result = await this.db
.prepare('SELECT * FROM steps WHERE itinerary_id = ? ORDER BY date ASC, time ASC')
.bind(itineraryId)
.prepare(query)
.bind(...bindings)
.all();

return (result.results || []).map(row => this.mapToStep(row));
return (result.results || []).map(row => this.mapToStep(row, options?.maskSecrets));
}

async get(stepId: string): Promise<Step | null> {
Expand Down Expand Up @@ -106,17 +129,26 @@ export class StepService {
return result.success;
}

private mapToStep(row: any): Step {
return {
private mapToStep(row: any, maskSecrets: boolean = true): Step {
const step: Step = {
id: row.id,
itinerary_id: row.itinerary_id,
title: row.title,
date: row.date,
time: row.time,
location: row.location,
notes: row.notes,
is_hidden: !!row.is_hidden_flag,
created_at: row.created_at,
updated_at: row.updated_at,
};

if (step.is_hidden && maskSecrets) {
step.title = '?????';
step.location = null;
step.notes = null;
}

return step;
}
}
2 changes: 1 addition & 1 deletion apps/web/src/lib/api/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { apiClient } from './client';

export const stepApi = {
list: (itineraryId: string) =>
apiClient.get<Step[]>(`/steps?itinerary_id=${itineraryId}`),
apiClient.get<Step[]>(`/steps?itinerary_id=${itineraryId}`, itineraryId),

get: (stepId: string) =>
apiClient.get<Step>(`/steps/${stepId}`),
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/lib/themes/standard-autumn/ItineraryView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
title?: string;
theme_id?: string;
memo?: string;
secret_settings?: {
enabled: boolean;
offset_minutes: number;
} | null;
}) => Promise<void>;
onCreateStep?: (data: {
title: string;
Expand Down Expand Up @@ -63,6 +67,11 @@
let showSettingsMenu = $state(false);
let selectedThemeId = $state(itinerary.theme_id || "standard-autumn");

let secretModeEnabled = $state(itinerary.secret_settings?.enabled ?? false);
let secretModeOffset = $state(
itinerary.secret_settings?.offset_minutes ?? 60,
);

let newStep = $state({
title: "",
date: "",
Expand Down Expand Up @@ -101,6 +110,12 @@
auth.setToken(itinerary.id, itinerary.title, token);
}
hasEditPermission = auth.hasEditPermission(itinerary.id);

// Auto-activate edit mode if no password and no token yet
if (!hasEditPermission && !itinerary.password) {
attemptEditModeActivation();
}

auth.updateAccessTime(itinerary.id, itinerary.title);
});

Expand Down Expand Up @@ -272,6 +287,17 @@
await onUpdateItinerary({ theme_id: themeId });
}
}

async function handleSecretModeUpdate() {
if (onUpdateItinerary) {
await onUpdateItinerary({
secret_settings: {
enabled: secretModeEnabled,
offset_minutes: secretModeOffset,
},
});
}
}
</script>

<div class="standard-autumn-theme">
Expand Down Expand Up @@ -559,6 +585,56 @@
</svg>
テーマを変更
</button>

<div class="standard-autumn-settings-divider"></div>

<div class="standard-autumn-settings-group">
<label class="standard-autumn-settings-toggle">
<span class="standard-autumn-settings-label-text">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width="20"
height="20"
>
<path
d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"
/>
</svg>
シークレットモード
</span>
<input
type="checkbox"
bind:checked={secretModeEnabled}
onchange={handleSecretModeUpdate}
class="standard-autumn-toggle-input"
/>
<span class="standard-autumn-toggle-slider"></span>
</label>

{#if secretModeEnabled}
<div class="standard-autumn-settings-subitem">
<span class="standard-autumn-settings-sublabel"
>表示開始:</span
>
<select
bind:value={secretModeOffset}
onchange={handleSecretModeUpdate}
class="standard-autumn-settings-select"
>
<option value={15}>15分前</option>
<option value={30}>30分前</option>
<option value={60}>1時間前</option>
<option value={120}>2時間前</option>
<option value={180}>3時間前</option>
<option value={300}>5時間前</option>
<option value={720}>12時間前</option>
<option value={1440}>24時間前</option>
</select>
</div>
{/if}
</div>
</div>
{/if}
{#if showThemeSelect}
Expand Down
Loading