Skip to content

Commit c95546a

Browse files
committed
訪問記録のエクスポートを、管理者が対象ユーザーを指定して作る方式にする
誰でも押せた「📦 エクスポート」(GET /api/visits/export)を廃止し、管理者が 管理画面でメールアドレスを指定して実行する形にした。写真ごとZIPにする処理は 重いので、生成はリクエストの外でバックグラウンドに回し、状態を export_jobs に 書き戻す(running → done / failed)。 - 全スポット種別を1つのZIPにまとめる(visits-<種別キー>.csv × 種別数 + 共通の photos/)。管理者が人ごとに1回実行すれば済むようにするため - ZIPは /app/exports(ホストの ./exports をbindマウント)に <ユーザーID>/<ジョブID>.zip で置き、DBには相対パスだけを持つ。photos/ と 分けたのは寿命が違うため —— 写真は消したら戻らない記録、ZIPは作り直せる 使い捨てで、同じユーザーのものは最新1件だけ残す(新しいものが done に なった時点で古い行とファイルを消す) - 実行できるのは admin だけ。他人の訪問記録と写真がまるごと入るため、 spot_admin・moderator には開けていない。ダウンロードは管理者と対象ユーザー 本人のみで、権限が無い場合も404にしてジョブの存在自体を伏せる - 対象ユーザーの入口はアカウント画面(作成ボタンは置かない) 生成状況は「画面を開いたとき・生成中は3秒ごと・画面が表に戻ったとき」の3つの 機会で取り直す(lib/useExportJobs.ts を管理画面とアカウント画面で共用)。 api.exports.list() はタブ内のGETキャッシュを通さない —— api-client の キャッシュは path をキーに最初の結果を持ち続けるため、サーバー側で勝手に進む 状態を載せると、リロードするまで作成中のまま画面が固まる。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fe814eb commit c95546a

24 files changed

Lines changed: 886 additions & 193 deletions

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ node_modules
66
.env.local
77
data
88
photos
9+
exports

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ PHOTO_STORAGE=fs
3131
# PHOTO_STORAGE=fs のときの保存先ディレクトリ(未設定ならcwd直下のphotos)
3232
# PHOTOS_DIR=
3333

34+
# 訪問記録エクスポートのZIPの置き場(未設定ならcwd直下のexports)。
35+
# docker-compose は ./exports を /app/exports にbindマウントする。
36+
# 写真と違いローカルFSのみ(Supabase Storageへの切り替えは持たない)
37+
# EXPORTS_DIR=
38+
3439
# PHOTO_STORAGE=supabase のときに必須。Supabaseプロジェクトの
3540
# Settings → API から取得する。service_role キーはサーバー専用なので絶対に
3641
# クライアントへ渡さないこと(NEXT_PUBLIC_ を付けない)。

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ yarn-error.log*
3838
/photos/*
3939
!/photos/.gitkeep
4040

41+
# 訪問記録エクスポートのZIP(docker-compose.ymlのbindマウント先、lib/exportStorage.ts)
42+
# 中身は個人情報そのもの。写真と同じくフォルダだけ .gitkeep で残す
43+
/exports/*
44+
!/exports/.gitkeep
45+
4146
# MapLibre GL JSのワーカースクリプト(scripts/copy-maplibre-worker.mjsが
4247
# node_modulesからコピーする生成物。理由はlib/maplibre.tsのコメント参照)
4348
/public/maplibre-gl/

CLAUDE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,20 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
322322

323323
かつては`date`型+`date_precision`列(`day`/`month`/`year`/`unknown`)で「年だけ分かる」等の粒度を持たせ、表示時に年月日を落としていたが、入力の手間に対して使われず廃止した(列ごと削除)。
324324

325-
自分の訪問記録は`/[type]/spots`の「最近の訪問場所」見出し右のボタンからZIPで一括エクスポートできる(`GET /api/visits/export?type=<種別キー>`。typeは必須で、その種別の分のみ。種別横断のエクスポートは意図的に持たない)。ZIPの中身は`visits.csv`(BOM付きUTF-8。訪問のメモ+スポット情報、`lib/csv.ts``buildCsv`)と`photos/<uuid>.<ext>`(添付写真。CSVの「写真」列がこのZIP内パスを指す)。ZIP生成は依存を増やさず`lib/zip.ts`の自前実装(無圧縮STORE。中身が圧縮済み画像と小さなCSVのみのため)で、写真は配信APIと同じく`parseVisitPhotoPath`の所有者チェックを通ったものだけを`readVisitPhoto`で読む(保存先の切り替えに追随する)。
325+
訪問記録のエクスポートは**管理者が対象ユーザーを指定して実行する**(下記「訪問記録のエクスポート」)。かつては`/[type]/spots`の「最近の訪問場所」右のボタンから本人がその場でダウンロードしていたが、写真ごとZIPにする処理が重く、実行を管理者に限ってバックグラウンドで作る形に変えた。
326+
327+
### 訪問記録のエクスポート(`export_jobs`)
328+
329+
1ユーザーの訪問記録(メモ+スポット情報のCSV)と添付写真を**全スポット種別ぶんまとめて1つのZIP**にする(`lib/visitExport.ts`。種別ごとに`visits-<種別キー>.csv`、写真は共通の`photos/`。CSVの「写真」列がZIP内の写真パスを指す)。
330+
331+
- **実行できるのは管理者だけ**(`POST /api/exports``{ email }`)。他人の訪問記録と写真がまるごと入るため、spot_admin・moderatorには開けていない(あちらはスポットの管理権限であって記録を見る権限ではない)
332+
- **生成はリクエストの外で走らせ、すぐ`running`のジョブを返す**。写真ごとまとめるため件数によっては数十秒以上かかり、待たせるとブラウザ側が先にタイムアウトする。完了・失敗は`export_jobs.status`に書き戻し、画面は実行中だけ3秒ごとに一覧を取り直す
333+
- **ZIPはコンテナ内の`/app/exports`**(ホストの`./exports`をbindマウント)に`<ユーザーID>/<ジョブID>.zip`で置き、DBには相対パスだけを持つ(写真と同じ持ち方)。**`photos/`と分けてある**のは寿命が違うため —— 写真は消したら戻らない記録、ZIPはいつでも作り直せる使い捨て。混ぜると掃除のときに消してよいものと消してはいけないものが並ぶ
334+
- **同じユーザーのZIPは最新1件だけ残す**。古いものは**新しいものが`done`になった時点で**ファイルごと消す(途中で失敗しても前回のZIPは残る)。写真の二重保持でディスクが膨らみ続けないようにするため
335+
- **ダウンロードできるのは管理者と対象ユーザー本人だけ**(`GET /api/exports/[id]/download`)。権限が無い場合も404にして、他人のジョブの存在自体を伏せる。本人の入口はアカウント画面(`AccountView`。出来上がっているときだけ出る。**作成ボタンは置かない**)
336+
- **生成状況は「画面を開いたとき・生成中は3秒ごと・画面が表に戻ったとき」の3つの機会で取り直す**(`lib/useExportJobs.ts`。管理画面とアカウント画面で共用)。**`api.exports.list()`はタブ内のGETキャッシュを通さない**(`request``fresh`オプション+`Cache-Control: no-store`) —— `lib/api-client.ts`のキャッシュはpathをキーに最初の結果を持ち続け、書き込み系が成功するまで消えないので、**サーバー側で勝手に進む状態を載せると作成中のまま画面が固まる**(リロードするまで完了に変わらない)。状態が進む口を足すときは同じように`fresh`を付けること
337+
- **コンテナが落ちると`running`のまま残る**。1時間を超えた`running`は画面側で失敗扱いにして出し(`ExportJobsPanel``STALE_RUNNING_MS`)、管理画面の削除ボタンで片付ける
338+
- 保存先は**ローカルFSのみ**(`lib/exportStorage.ts`)。写真と違い`PHOTO_STORAGE=supabase`のような切り替えは持たない —— 永続ディスクを持てない環境ではバックグラウンド生成そのものが成立しないため
326339

327340
### 未訪問記録(`visits.unvisited`)と非表示スポット(`spot_hides`)
328341

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,11 @@ docker compose pull && docker compose up -d
220220
- **口コミ(公開)**: 星評価はなく本文のみ。スポット1件につきユーザー1人1件(再投稿で上書き)
221221
- **写真(非公開)**: 自分の訪問記録にのみ添付。ブラウザ側で縮小・圧縮した上で`photos/`
222222
フォルダ(Dockerではbindマウント)に保存され、`/api/photos/...`経由(本人のみ)で配信する
223-
- **訪問記録のエクスポート**: `/[type]/spots`の「最近の訪問場所」右のボタンから、その種別の
224-
自分の訪問記録をZIPで一括ダウンロードできる(`visits.csv`=訪問のメモ+スポット情報、
225-
`photos/`=添付写真)
223+
- **訪問記録のエクスポート**: 管理者が`/[type]/admin`で対象ユーザーのメールアドレスを
224+
指定して実行すると、そのユーザーの訪問記録を全スポット種別ぶんまとめたZIPが
225+
バックグラウンドで作られる(`visits-<種別キー>.csv`=訪問のメモ+スポット情報、
226+
`photos/`=添付写真)。出来上がったZIPは管理画面と、対象ユーザー本人のアカウント画面から
227+
ダウンロードできる。ZIPは`exports/`に置かれ、同じユーザーのものは最新1件だけ残る
226228

227229
スポット詳細にはWikipedia検索による概要表示機能もある(種別ごとにON/OFF・参照言語版を設定可)。
228230

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUser } from "@/lib/auth/current-user";
4+
import { readExportZip } from "@/lib/exportStorage";
5+
6+
/**
7+
* 出来上がったエクスポートZIPをダウンロードする。
8+
* **落とせるのは管理者と、そのZIPの対象ユーザー本人だけ**(中身は本人の訪問記録と
9+
* 写真そのもの)。写真の配信(app/api/photos/[...path])と同じく、URLを知っていても
10+
* 他人のものは落とせない。
11+
*/
12+
export async function GET(
13+
_request: Request,
14+
{ params }: { params: Promise<{ id: string }> }
15+
) {
16+
const user = await getCurrentUser();
17+
if (!user) {
18+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
19+
}
20+
const { id } = await params;
21+
22+
const { rows } = await query<{
23+
user_id: string;
24+
user_email: string;
25+
status: string;
26+
file_path: string | null;
27+
finished_at: Date | null;
28+
}>(
29+
`select j.user_id, u.email as user_email, j.status, j.file_path, j.finished_at
30+
from export_jobs j join users u on u.id = j.user_id
31+
where j.id = $1`,
32+
[id]
33+
);
34+
const job = rows[0];
35+
// 権限が無い場合も404にする(他人のジョブの存在自体を伏せる)
36+
if (!job || (user.role !== "admin" && job.user_id !== user.id)) {
37+
return NextResponse.json({ error: "not found" }, { status: 404 });
38+
}
39+
if (job.status !== "done" || !job.file_path) {
40+
return NextResponse.json({ error: "not ready" }, { status: 409 });
41+
}
42+
43+
const data = await readExportZip(job.file_path);
44+
if (!data) {
45+
return NextResponse.json({ error: "file not found" }, { status: 410 });
46+
}
47+
48+
// ファイル名はJSTの日付+メールアドレスのローカル部(誰のものか開く前に分かるように。
49+
// ASCII以外・記号はファイル名に使えない環境があるため英数字だけに落とす)
50+
const localPart = job.user_email.split("@")[0].replace(/[^A-Za-z0-9._-]/g, "");
51+
const jstDate = new Date((job.finished_at ?? new Date()).getTime() + 9 * 60 * 60 * 1000)
52+
.toISOString()
53+
.slice(0, 10)
54+
.replace(/-/g, "");
55+
const filename = `travel-log-visits-${localPart || "user"}-${jstDate}.zip`;
56+
57+
return new NextResponse(new Uint8Array(data), {
58+
headers: {
59+
"Content-Type": "application/zip",
60+
"Content-Disposition": `attachment; filename="${filename}"`,
61+
"Cache-Control": "no-store",
62+
},
63+
});
64+
}

app/api/exports/[id]/route.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUser } from "@/lib/auth/current-user";
4+
import { deleteExportZip } from "@/lib/exportStorage";
5+
6+
/**
7+
* エクスポートのジョブと、出来上がっているZIPを削除する(管理者のみ)。
8+
* 同じユーザーの新しいZIPが出来れば古いものは自動で消えるが、
9+
* 失敗したジョブや、コンテナが落ちて running のまま残った行を片付けるために要る。
10+
*/
11+
export async function DELETE(
12+
_request: Request,
13+
{ params }: { params: Promise<{ id: string }> }
14+
) {
15+
const user = await getCurrentUser();
16+
if (!user) {
17+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
18+
}
19+
if (user.role !== "admin") {
20+
return NextResponse.json({ error: "forbidden" }, { status: 403 });
21+
}
22+
const { id } = await params;
23+
24+
const { rows } = await query<{ file_path: string | null }>(
25+
"delete from export_jobs where id = $1 returning file_path",
26+
[id]
27+
);
28+
if (rows.length === 0) {
29+
return NextResponse.json({ error: "not found" }, { status: 404 });
30+
}
31+
if (rows[0].file_path) await deleteExportZip(rows[0].file_path);
32+
33+
return NextResponse.json({ data: { ok: true } });
34+
}

app/api/exports/route.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUser } from "@/lib/auth/current-user";
4+
import { buildVisitExportZip } from "@/lib/visitExport";
5+
import { deleteExportZip, saveExportZip } from "@/lib/exportStorage";
6+
import type { ExportJob } from "@/lib/types";
7+
8+
/** 一覧・作成の返却に使う共通のSELECT(対象ユーザーのメールアドレスも返す) */
9+
const JOB_SELECT = `
10+
select j.id, j.user_id, u.email as user_email, j.requested_by,
11+
j.status, j.file_size::float8 as file_size, j.visit_count, j.photo_count, j.error,
12+
j.created_at, j.finished_at
13+
from export_jobs j
14+
join users u on u.id = j.user_id`;
15+
16+
/**
17+
* 訪問記録エクスポートのジョブ一覧。
18+
* **管理者は全件、それ以外は自分が対象のものだけ**(アカウント画面で
19+
* 自分のZIPをダウンロードするため)。ファイルの実パスは返さない。
20+
*/
21+
export async function GET() {
22+
const user = await getCurrentUser();
23+
if (!user) {
24+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
25+
}
26+
27+
const { rows } =
28+
user.role === "admin"
29+
? await query<ExportJob>(`${JOB_SELECT} order by j.created_at desc`)
30+
: await query<ExportJob>(
31+
`${JOB_SELECT} where j.user_id = $1 order by j.created_at desc`,
32+
[user.id]
33+
);
34+
35+
// 生成状況を見に来る口なので、経路上のどこにもキャッシュさせない
36+
// (クライアント側もタブ内キャッシュを使わずに取りに来る。lib/api-client.ts)
37+
return NextResponse.json(
38+
{ data: rows },
39+
{ headers: { "Cache-Control": "no-store" } }
40+
);
41+
}
42+
43+
/**
44+
* 対象ユーザーのメールアドレスを指定してエクスポートを開始する(管理者のみ)。
45+
*
46+
* **ZIPの生成はリクエストの外で走らせ、すぐに running のジョブを返す** ——
47+
* 写真ごとまとめるため件数によっては数十秒以上かかり、待たせるとブラウザ側が
48+
* 先にタイムアウトするため。完了・失敗はジョブの status に書き戻す。
49+
*/
50+
export async function POST(request: Request) {
51+
const user = await getCurrentUser();
52+
if (!user) {
53+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
54+
}
55+
// 他人の訪問記録と写真がまるごと入るため、管理者だけに限る
56+
// (spot_admin・moderatorはスポットの管理権限であって、記録を見る権限ではない)
57+
if (user.role !== "admin") {
58+
return NextResponse.json({ error: "forbidden" }, { status: 403 });
59+
}
60+
61+
const body = await request.json();
62+
const email = typeof body?.email === "string" ? body.email.trim() : "";
63+
if (!email) {
64+
return NextResponse.json(
65+
{ error: "メールアドレスを入力してください。" },
66+
{ status: 400 }
67+
);
68+
}
69+
70+
const { rows: targets } = await query<{ id: string }>(
71+
"select id from users where lower(email) = lower($1)",
72+
[email]
73+
);
74+
const targetId = targets[0]?.id;
75+
if (!targetId) {
76+
return NextResponse.json(
77+
{ error: `「${email}」のユーザーが見つかりません。` },
78+
{ status: 404 }
79+
);
80+
}
81+
82+
// 同じユーザーの生成が二重に走らないようにする(片方の結果が捨てられるだけで
83+
// 害は無いが、重い処理を無駄に並べない)
84+
const { rows: running } = await query(
85+
"select 1 from export_jobs where user_id = $1 and status = 'running'",
86+
[targetId]
87+
);
88+
if (running.length > 0) {
89+
return NextResponse.json(
90+
{ error: "このユーザーのエクスポートは実行中です。" },
91+
{ status: 409 }
92+
);
93+
}
94+
95+
const { rows: created } = await query<{ id: string }>(
96+
`insert into export_jobs (user_id, requested_by, status)
97+
values ($1, $2, 'running')
98+
returning id`,
99+
[targetId, user.id]
100+
);
101+
const jobId = created[0].id;
102+
103+
// 応答を返したあとも動き続ける(このアプリは常駐のNodeサーバーで動く前提)。
104+
// コンテナが落ちるとrunningのまま残るため、画面側で古いrunningは失敗として扱う
105+
void runExportJob(jobId, targetId);
106+
107+
const { rows } = await query<ExportJob>(`${JOB_SELECT} where j.id = $1`, [
108+
jobId,
109+
]);
110+
return NextResponse.json({ data: rows[0] });
111+
}
112+
113+
/**
114+
* ZIPを組んで保存し、ジョブを done/failed にする。
115+
* **成功したときだけ、同じユーザーの古いジョブとファイルを消す**
116+
* (途中で失敗しても、前回のZIPは残しておく)。
117+
*/
118+
async function runExportJob(jobId: string, targetUserId: string) {
119+
try {
120+
const { zip, visitCount, photoCount } =
121+
await buildVisitExportZip(targetUserId);
122+
// 写真と同じく<ユーザーID>/配下に置く(人ごとにまとめて消せる)
123+
const relPath = `${targetUserId}/${jobId}.zip`;
124+
await saveExportZip(relPath, zip);
125+
126+
const { rows: old } = await query<{ id: string; file_path: string | null }>(
127+
"select id, file_path from export_jobs where user_id = $1 and id <> $2",
128+
[targetUserId, jobId]
129+
);
130+
131+
await query(
132+
`update export_jobs
133+
set status = 'done', file_path = $2, file_size = $3,
134+
visit_count = $4, photo_count = $5, error = null,
135+
finished_at = now()
136+
where id = $1`,
137+
[jobId, relPath, zip.length, visitCount, photoCount]
138+
);
139+
140+
for (const row of old) {
141+
if (row.file_path) await deleteExportZip(row.file_path);
142+
}
143+
if (old.length > 0) {
144+
await query(
145+
"delete from export_jobs where id = any($1::uuid[])",
146+
[old.map((r) => r.id)]
147+
);
148+
}
149+
} catch (e) {
150+
await query(
151+
`update export_jobs
152+
set status = 'failed', error = $2, finished_at = now()
153+
where id = $1`,
154+
[jobId, e instanceof Error ? e.message : String(e)]
155+
);
156+
}
157+
}

0 commit comments

Comments
 (0)