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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ ICAL_TOKEN=生成したトークン文字列
- **更新頻度**: Pull 型のため、カレンダーアプリのポーリング間隔に依存します。Google Calendar・Outlook は約24時間、Apple カレンダーは約15分が目安です(リアルタイム更新は現行の Google Calendar 直接同期が適切)。
- **連続シフトの統合**: 同一ユーザーの連続する30分シフトは1つのイベントにまとめて表示されます(例: 13:00-13:30 + 13:30-14:00 → 13:00-14:00)。
- **特別シフトも含む**: 通常シフト(`shifts`)と特別シフト(`special_shifts`)の両方が含まれます。
- **カレンダー名**: 購読するとカレンダーアプリ上に、個人用は「〇〇さんのシフト」(シフトが0件の場合は「マイシフト」)、全体用は「全員のシフト」という名前で追加されます。複数のカレンダーを購読しても区別できます。
- **セキュリティ**: URLに含まれるトークンが認証情報です。購読URLは他人に共有しないでください。漏洩した場合は `.env` の `ICAL_TOKEN` を再生成してサーバーを再起動すると、全ユーザーの購読URLが失効し、新しいURLが発行されます。

## 使用技術
Expand Down
2 changes: 1 addition & 1 deletion help.html
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ <h3>設定の手順</h3>

<div class="step">
<strong>6. 完了</strong>
<p>左側のカレンダー一覧に「シフト管理」が追加され、自分のシフトが予定として表示されます。</p>
<p>左側のカレンダー一覧に「<strong>〇〇さんのシフト</strong>」(〇〇はあなたの名前)が追加され、自分のシフトが予定として表示されます。</p>
</div>
</div>

Expand Down
20 changes: 16 additions & 4 deletions js/modules/shifts.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ function displayCapacityOnAdminCalendar(capacityData) {
});
}

// サーバーが返す「/api/...」形式のパスを、貼り付け可能な絶対URLに変換する
// 本番は /shift2/ のようなサブパス配下で配信されるため、
// window.location.origin ではなく config.API_BASE_URL を基準にする
function toAbsoluteApiUrl(relativeUrl) {
return config.API_BASE_URL.replace(/\/api$/, '') + relativeUrl;
}

// iCal購読URL(全シフト・管理者用)を入力欄にセットする
async function updateIcalUrl() {
const input = document.getElementById('icalAllUrl');
Expand All @@ -126,8 +133,7 @@ async function updateIcalUrl() {
try {
const result = await API.getAllIcalUrl(currentUser.email);
if (result.success && result.data && result.data.url) {
// サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする
input.value = new URL(result.data.url, window.location.origin).href;
input.value = toAbsoluteApiUrl(result.data.url);
} else {
input.value = '(購読URLを取得できませんでした)';
}
Expand Down Expand Up @@ -164,8 +170,7 @@ async function updateMyIcalUrl() {
try {
const result = await API.getMyIcalUrl(currentUser.sub);
if (result.success && result.data && result.data.url) {
// サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする
input.value = new URL(result.data.url, window.location.origin).href;
input.value = toAbsoluteApiUrl(result.data.url);
} else {
input.value = '(購読URLを取得できませんでした)';
}
Expand Down Expand Up @@ -854,3 +859,10 @@ async function deleteMyShift(buttonElement, uuids, isSpecial) {
alert('シフト情報の取得に失敗しました。');
}
}

// テストから実コードを検証できるようにするエクスポートガード
// (ブラウザでは module が未定義のため no-op になる)
// 方針: docs/refactoring/phase-1-test-foundation.md
if (typeof module !== 'undefined' && module.exports) {
module.exports = { toAbsoluteApiUrl };
}
39 changes: 39 additions & 0 deletions server/src/__tests__/ICalService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,45 @@ describe('ICalService', () => {
});
});

describe('カレンダー名', () => {
test('個人用カレンダーには本人の名前が入る(誰のシフトか分かるように)', () => {
const mockShifts = [
{ id: 1, uuid: 'shift-1', user_id: 'user-1', user_name: '石原純也',
email: 'a@example.com', date: '2026-04-21', time_slot: '13:00-13:30',
calendar_event_id: null, created_at: '', updated_at: '' },
];
(ShiftModel.getByUserId as jest.Mock).mockReturnValue(mockShifts);

const result = ICalService.generateForUser('user-1');

expect(result).toContain('X-WR-CALNAME:石原純也さんのシフト');
});

test('名前が取得できない場合でも「マイシフト」として識別できる', () => {
const result = ICalService.generateForUser('user-1');

expect(result).toContain('X-WR-CALNAME:マイシフト');
});

test('特別シフトしかない場合も本人の名前が入る', () => {
const mockApps = [
{ uuid: 'app-1', special_shift_uuid: 'shift-1', user_id: 'user-1', user_name: '石原純也',
email: 'a@example.com', time_slot: '13:00-13:30', date: '2026-04-20', created_at: '', updated_at: '' },
];
(SpecialShiftApplicationModel.getAllWithShiftInfo as jest.Mock).mockReturnValue(mockApps);

const result = ICalService.generateForUser('user-1');

expect(result).toContain('X-WR-CALNAME:石原純也さんのシフト');
});

test('全体用カレンダーは全員分だと分かる名前にする', () => {
const result = ICalService.generateAll();

expect(result).toContain('X-WR-CALNAME:全員のシフト');
});
});

describe('generateForUser(userId)', () => {
test('特定ユーザーの特別シフト申請のみ含まれる', () => {
const mockApps = [
Expand Down
17 changes: 13 additions & 4 deletions server/src/services/ICalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class ICalService {
* 同一ユーザーの連続するシフトはひとつのイベントにまとめる
*/
static generateAll(): string {
const cal = ical({ name: 'シフト管理', timezone: TIMEZONE });
const cal = ical({ name: '全員のシフト', timezone: TIMEZONE });

addMergedShiftEvents(cal, ShiftModel.getAll());
addMergedSpecialShiftApplicationEvents(cal, SpecialShiftApplicationModel.getAllWithShiftInfo(undefined));
Expand All @@ -84,10 +84,19 @@ export class ICalService {
* 同一ユーザーの連続するシフトはひとつのイベントにまとめる
*/
static generateForUser(userId: string): string {
const cal = ical({ name: 'シフト管理', timezone: TIMEZONE });
const shifts = ShiftModel.getByUserId(userId);
const specialApps = SpecialShiftApplicationModel.getAllWithShiftInfo(userId);

addMergedShiftEvents(cal, ShiftModel.getByUserId(userId));
addMergedSpecialShiftApplicationEvents(cal, SpecialShiftApplicationModel.getAllWithShiftInfo(userId));
// カレンダーアプリ上で「誰のシフトか」が分かるよう本人の名前を使う。
// シフトが1件もない場合は名前を取得できないため既定名にする。
const userName = shifts[0]?.user_name || specialApps[0]?.user_name;
const cal = ical({
name: userName ? `${userName}さんのシフト` : 'マイシフト',
timezone: TIMEZONE
});

addMergedShiftEvents(cal, shifts);
addMergedSpecialShiftApplicationEvents(cal, specialApps);

return cal.toString();
}
Expand Down
68 changes: 68 additions & 0 deletions test/ical-url-subpath.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* iCal購読URLのサブパス対応テスト
*
* 回帰防止: 本番は https://tukumana.si.aoyama.ac.jp/shift2/ のように
* サブパス配下で配信されている。
* window.location.origin を基準に絶対URLを組み立てると
* /shift2 が欠落し、購読URLが 404 になる。
*
* このテストは実コード(js/modules/shifts.js)を直接 import して検証する。
* 方針: docs/refactoring/phase-1-test-foundation.md
*/

const { toAbsoluteApiUrl } = require('../js/modules/shifts.js');

describe('iCal購読URLのサブパス対応', () => {
afterEach(() => {
delete global.config;
});

describe('サブパス配下(本番相当: /shift2/)', () => {
beforeEach(() => {
global.config = { API_BASE_URL: 'https://tukumana.si.aoyama.ac.jp/shift2/api' };
});

test('購読URLに /shift2 が含まれる', () => {
const url = toAbsoluteApiUrl('/api/ical/user/112204345149724665942?token=abc');

expect(url).toBe('https://tukumana.si.aoyama.ac.jp/shift2/api/ical/user/112204345149724665942?token=abc');
});

test('/shift2 が欠落しない(欠落すると本番で404になる)', () => {
const url = toAbsoluteApiUrl('/api/ical/user/u1?token=abc');

expect(url).toContain('/shift2/api/ical/');
expect(url).not.toBe('https://tukumana.si.aoyama.ac.jp/api/ical/user/u1?token=abc');
});

test('管理者用(全シフト)URLにも /shift2 が含まれる', () => {
const url = toAbsoluteApiUrl('/api/ical/all?token=admin');

expect(url).toBe('https://tukumana.si.aoyama.ac.jp/shift2/api/ical/all?token=admin');
});

test('トークンのクエリが壊れない', () => {
const url = toAbsoluteApiUrl('/api/ical/user/u1?token=8xm4WIDUM8v4gVTxpFJIZY6S7n9XzUwn7UphY0mqQZY');

expect(url).toContain('?token=8xm4WIDUM8v4gVTxpFJIZY6S7n9XzUwn7UphY0mqQZY');
});
});

describe('ルート直下(ローカル開発相当)', () => {
beforeEach(() => {
global.config = { API_BASE_URL: 'http://localhost:3000/api' };
});

test('サブパスがない場合もそのまま組み立てられる', () => {
const url = toAbsoluteApiUrl('/api/ical/user/u1?token=abc');

expect(url).toBe('http://localhost:3000/api/ical/user/u1?token=abc');
});

test('余分なパスが混入しない', () => {
const url = toAbsoluteApiUrl('/api/ical/all?token=abc');

expect(url).toBe('http://localhost:3000/api/ical/all?token=abc');
});
});
});
Loading