|
| 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