255 lines
13 KiB
TypeScript
255 lines
13 KiB
TypeScript
import express, { Router } from 'express';
|
||
import { join } from 'node:path';
|
||
import { readdirSync, statSync } from 'node:fs';
|
||
import { spaceFilesDir } from '../spaces/paths.js';
|
||
import { canEditInSpace } from './visibility.js';
|
||
import { viewerOf } from './space-viewer.js';
|
||
import type { SpaceApiDeps } from './space-api.js';
|
||
|
||
const MAX_TZ_OFFSET = 14 * 60; // ±14h は IANA TZ の理論上限
|
||
|
||
/** tz_offset クエリ(分、-getTimezoneOffset() 由来で JST=+540)を安全に整数化する。 */
|
||
export function parseTzOffset(raw: unknown): number {
|
||
const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
|
||
if (!Number.isFinite(n)) return 0;
|
||
return Math.max(-MAX_TZ_OFFSET, Math.min(MAX_TZ_OFFSET, n));
|
||
}
|
||
|
||
/** 'YYYY-MM' の月初・月末(ローカル暦日)を返す。 */
|
||
export function monthBounds(month: string): { monthStart: string; monthEnd: string } {
|
||
const [y, m] = month.split('-').map((s) => parseInt(s, 10));
|
||
const start = `${month}-01`;
|
||
// 当月末日 = 翌月0日(Date は UTC ベースだが日付計算のみに使うので TZ 非依存)。
|
||
const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||
const end = `${month}-${String(lastDay).padStart(2, '0')}`;
|
||
return { monthStart: start, monthEnd: end };
|
||
}
|
||
|
||
/** UTC instant(ms)をローカル TZ オフセット分ずらして暦日 'YYYY-MM-DD' に落とす。 */
|
||
function localDayOfMs(ms: number, tzOffsetMin: number): string {
|
||
return new Date(ms + tzOffsetMin * 60_000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
/**
|
||
* spaceFilesDir 配下を再帰スキャンし、mtime のローカル暦日が date と一致する
|
||
* ファイルを列挙する。runs/(実行ログ)・.conflict・ドットファイル/ディレクトリは
|
||
* 除外。ディレクトリが無ければ空配列(エラーにしない)。
|
||
*/
|
||
function scanFilesForLocalDay(
|
||
rootDir: string,
|
||
date: string,
|
||
tzOffsetMin: number,
|
||
): Array<{ name: string; path: string; size: number; mtime: string }> {
|
||
const out: Array<{ name: string; path: string; size: number; mtime: string }> = [];
|
||
const walk = (absDir: string, relDir: string): void => {
|
||
let entries: import('node:fs').Dirent[];
|
||
try {
|
||
entries = readdirSync(absDir, { withFileTypes: true });
|
||
} catch {
|
||
return; // ディレクトリ非存在・読めない場合は黙ってスキップ
|
||
}
|
||
for (const entry of entries) {
|
||
// runs/(実行ログ)・.conflict・全ドットファイルを除外
|
||
if (entry.name === 'runs' || entry.name === '.conflict' || entry.name.startsWith('.')) continue;
|
||
const abs = join(absDir, entry.name);
|
||
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||
if (entry.isDirectory()) {
|
||
walk(abs, rel);
|
||
continue;
|
||
}
|
||
if (!entry.isFile()) continue;
|
||
let stat;
|
||
try {
|
||
stat = statSync(abs);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (localDayOfMs(stat.mtimeMs, tzOffsetMin) === date) {
|
||
out.push({ name: entry.name, path: rel, size: stat.size, mtime: stat.mtime.toISOString() });
|
||
}
|
||
}
|
||
};
|
||
walk(rootDir, '');
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* スペースのカレンダー(予定 + 日次集計)。createSpaceApi から分離(巨大関数分割)。挙動は等価。
|
||
*
|
||
* 可視性は他のスペース配下ルートと同じく getSpace({viewer}) が null を返したら
|
||
* 404。書き込み(POST/PATCH/DELETE)は canEditInSpace(owner / admin / member.role∈
|
||
* {owner,editor})で更にゲート。viewer ロールのメンバーは閲覧のみで編集不可。
|
||
* 他スペースのイベント id への操作は spaceId 不一致で 404(buildVisibilityWhere
|
||
* 同様のスコープ)。日付バケツ化のローカル TZ オフセットは tz_offset(分)で受ける
|
||
* (Usage ダッシュボードと同方式)。
|
||
* spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md
|
||
*/
|
||
export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): void {
|
||
const { repo, worktreeDir } = deps;
|
||
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
|
||
|
||
// 月ビュー: 日別カウント + 当月の予定一覧
|
||
router.get('/:id/calendar', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const month = String(req.query.month ?? '');
|
||
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||
return res.status(400).json({ error: 'month must be YYYY-MM' });
|
||
}
|
||
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
|
||
const { monthStart, monthEnd } = monthBounds(month);
|
||
// Personal spaces also own the owner's space-less (space_id NULL) tasks.
|
||
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
|
||
const result = await repo.getSpaceCalendarMonth(space.id, { monthStart, monthEnd, tzOffsetMin, personalOwnerId });
|
||
res.json(result);
|
||
});
|
||
|
||
// 日詳細: その日に作成されたタスク / 変更ファイル / 予定
|
||
router.get('/:id/calendar/day', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const date = String(req.query.date ?? '');
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
|
||
}
|
||
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
|
||
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
|
||
const { tasks, events } = await repo.getSpaceCalendarDay(space.id, date, tzOffsetMin, personalOwnerId);
|
||
const files = scanFilesForLocalDay(spaceFilesDir(worktreeDir, space.id), date, tzOffsetMin);
|
||
res.json({ tasks, files, events });
|
||
});
|
||
|
||
// 予定の作成(編集権限保有者のみ)
|
||
router.post('/:id/calendar/events', jsonParser, async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
const date = (req.body?.date ?? '').toString();
|
||
const title = (req.body?.title ?? '').toString().trim();
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
|
||
if (!title) return res.status(400).json({ error: 'title is required' });
|
||
const time = req.body?.time != null && req.body.time !== '' ? String(req.body.time) : null;
|
||
if (time != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) {
|
||
return res.status(400).json({ error: 'time must be HH:MM' });
|
||
}
|
||
let endDate: string | null = null;
|
||
if (req.body?.end_date != null && req.body.end_date !== '') {
|
||
endDate = String(req.body.end_date);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
|
||
if (endDate < date) return res.status(400).json({ error: 'end_date must be on or after date' });
|
||
}
|
||
// 終了時刻。開始時刻があるときのみ持てる。単日は開始以降を強制(複数日は終了日側の時刻なので順序不問)。
|
||
const endTime = req.body?.end_time != null && req.body.end_time !== '' ? String(req.body.end_time) : null;
|
||
if (endTime != null) {
|
||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(endTime)) return res.status(400).json({ error: 'end_time must be HH:MM' });
|
||
if (time == null) return res.status(400).json({ error: 'end_time requires a start time' });
|
||
const multiDay = endDate != null && endDate > date;
|
||
if (!multiDay && endTime < time) return res.status(400).json({ error: 'end_time must be on or after the start time' });
|
||
}
|
||
const ownerId = viewer.id === 'local' ? null : viewer.id;
|
||
const ev = await repo.createCalendarEvent({
|
||
spaceId: space.id,
|
||
ownerId,
|
||
date,
|
||
endDate,
|
||
time,
|
||
endTime,
|
||
title,
|
||
description: req.body?.description != null ? String(req.body.description) : null,
|
||
createdBy: 'user',
|
||
});
|
||
res.status(201).json(ev);
|
||
});
|
||
|
||
// 予定の更新(編集権限保有者のみ、他スペースの id は 404)
|
||
router.patch('/:id/calendar/events/:eventId', jsonParser, async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
const eventId = Number(req.params.eventId);
|
||
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
|
||
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
|
||
const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null } = {};
|
||
if (req.body?.date !== undefined) {
|
||
const d = String(req.body.date);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
|
||
patch.date = d;
|
||
}
|
||
// 終了日。null/'' で単日に戻す。開始日(更新後の値)より前は 400。
|
||
const effectiveStart = patch.date ?? existing.date;
|
||
if (req.body?.end_date !== undefined) {
|
||
if (req.body.end_date === null || req.body.end_date === '') {
|
||
patch.endDate = null;
|
||
} else {
|
||
const ed = String(req.body.end_date);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ed)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
|
||
if (ed < effectiveStart) return res.status(400).json({ error: 'end_date must be on or after date' });
|
||
patch.endDate = ed > effectiveStart ? ed : null;
|
||
}
|
||
} else if (patch.date !== undefined && existing.endDate && existing.endDate < patch.date) {
|
||
// 開始日だけを既存終了日より後ろにずらした場合は整合のため単日へ。
|
||
patch.endDate = null;
|
||
}
|
||
if (req.body?.time !== undefined) {
|
||
const t = req.body.time === null || req.body.time === '' ? null : String(req.body.time);
|
||
if (t != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(t)) return res.status(400).json({ error: 'time must be HH:MM' });
|
||
patch.time = t;
|
||
}
|
||
// 終了時刻。null/'' で解除。形式のみここで検証し、順序・開始有無は実効値で下記一括チェック。
|
||
if (req.body?.end_time !== undefined) {
|
||
const et = req.body.end_time === null || req.body.end_time === '' ? null : String(req.body.end_time);
|
||
if (et != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(et)) return res.status(400).json({ error: 'end_time must be HH:MM' });
|
||
patch.endTime = et;
|
||
}
|
||
// 時刻の整合は「更新後の実効値」で検証する。time/date/end_date だけを変えて
|
||
// end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、
|
||
// 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。
|
||
{
|
||
const effTime = patch.time !== undefined ? patch.time : existing.time;
|
||
const effEndTime = patch.endTime !== undefined ? patch.endTime : existing.endTime;
|
||
if (effEndTime != null) {
|
||
if (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' });
|
||
const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate;
|
||
const multiDay = effEnd != null && effEnd > effectiveStart;
|
||
if (!multiDay && effEndTime < effTime) return res.status(400).json({ error: 'end_time must be on or after the start time' });
|
||
}
|
||
}
|
||
if (req.body?.title !== undefined) {
|
||
const t = String(req.body.title).trim();
|
||
if (!t) return res.status(400).json({ error: 'title is required' });
|
||
patch.title = t;
|
||
}
|
||
if (req.body?.description !== undefined) {
|
||
patch.description = req.body.description === null ? null : String(req.body.description);
|
||
}
|
||
const updated = await repo.updateCalendarEvent(eventId, patch);
|
||
res.json(updated);
|
||
});
|
||
|
||
// 予定の削除(編集権限保有者のみ、他スペースの id は 404)
|
||
router.delete('/:id/calendar/events/:eventId', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
const eventId = Number(req.params.eventId);
|
||
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
|
||
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
|
||
await repo.deleteCalendarEvent(eventId);
|
||
res.status(204).end();
|
||
});
|
||
}
|