[LINE][Firebase][Cloud Storage]道路通報アプリ
本記事の分類
- 学習ノート
機能
- LINE公式アカウントのリッチメニューから投稿ページを開く
- 道路の不具合情報(内容、写真、位置)を投稿する
- 道路の不具合情報をFirestore、Cloud Storage(国内region)に保存する
- 投稿者のラインアカウントへ投稿(受付)内容を送信する
想定シーン
- 道路の穴ボコ等をみつけた人が土木事務所にラインから通報する
仕様
- システム仕様は下図のとおり

- スマホ画面の仕様は下図のとおり
- 管理画面の仕様は下図のとおり

Firebaseの設定
- FirebaseのTOPページを開く

- コンソールへ移動をクリック

- ”新しいFirebaseプロジェクトを作成”をクリック

- 任意のプロジェクト名(ここでは”sample-app"とした。)を入力し、”続行”をクリック。

- ”続行”をクリック

- ”続行”をクリック

- ”Default Account for Firebase”をクリック
- ”プロジェクトを作成”をクリック

- ”続行”をクリック

- ”Sparkプラン”をクリック

- 従量制の”プランを選択”をクリック

- ”Cloud 請求先アカウントを作成する”をクリック

- ”確認”をクリック

- ”連絡先情報”を設定
- ”連絡先情報”を設定
- ”購入を確定”をクリック
 を選択ed801-20260105.png)
- JPYの予算額を入力(ここでは500円とした。)
- ”続行”をクリック

- ”Cloud請求先アカウントをリンク”をクリック

- "完了”をクリック
Google Cloudの設定

- Google Cloud のページを開き、右上の”コンソール”をクリック

- Google Cloud 横のアイコンをクリック
- 新しいプロジェクトをクリック

- "作成”をクリック

- ロゴ「Google Cloud」の右のボタンをクリック
- 先程作成したプロジェクト名を選択

- ロゴ「Google Cloud」の左の三本線をクリック

- ”課金”をクリック

- ”リンクされた請求先のアカウントに移動”をクリック

- 概要が表示されることを確認する

- ”APIとサービス”→”ライブラリ”をクリック

- "cloud functions api"と入力し検索

- ”Cloud Functions API”をクリック

- ”有効にする”をクリック
同様に以下APIを有効化する
- Google Cloud Firestore API
- Cloud Storage API
- Cloud Build API
Firestore (データベース) の設定
- FirebaseのTOPページを開く
https://console.firebase.google.com/?hl=ja

- 作成したProjectの名称をクリック(ここではsample-app)

- ”構築”→”Firestore Database”をクリック

- "データベースの作成”をクリック

- ”次へ”をクリック

- ”ロケーション”を ”asia-northeast1(TOKYO)”に設定
- ”次へ”をクリック

- ”作成”をクリック
Cloud Storage (ストレージ) の設定

- ”構築”→”Storage"をクリック

- ”使ってみる”をクリック

- ”ロケーション”→”リージョン”→”ASIA-EAST1"をクリック

- ”作成”をクリック
プロジェクトフォルダの作成
- 任意の場所にプロジェクトフォルダを作成
mkdir tutorial-for-report-app-about-road-firebase
cd tutorial-for-report-app-about-road-firebase
※フォルダ名は任意に命名して問題ない
Firebaseのインストールと設定
sudo apt-get update
sudo apt install npm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
sudo npm install -g firebase-tools
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
nvm install --lts
firebase login
※termanl上でnode --versionを実行しversion情報が出ない場合は、node.jsをインストールしてください。
firebase login


- 認証のため表示されたURLをctrl + クリック
firebase init
firebase init を実⾏、以下のように回答

. Which Firebase features do you want to set up?
→ Functions と Hosting を選択(スペースキーで選択、エンターキーで決定)


. Please select an option:
→ Use an existing project を選択
. Select a default Firebase project:
→ 先ほど作成した road-report-app を選択

. What language would you like to use to write Cloud Functions?
→ JavaScript を選択

. Do you want to use ESLint...?
→ Yes (y) を選択(コードの品質を保つため)

. Do you want to install dependencies with npm now?
→ Yes (y) を選択

. What do you want to use as your public directory?
→ public のままでエンターキーを押す

. Configure as a single-page app?
→ No (n) を選択

. Set up automatic builds and deploys with GitHub?
→ No (n) を選択


- 完了すると、プロジェクトフォルダ内にfunctionsフォルダやpublicフォルダ、firebase.jsonなどが自動的に作成される
フォルダとファイルの追加
- editorは何でも良いです
- 作成ファイルは、functions/index.html、publicフォルダ配下のファイル

functions/index.js
- 以下のとおり
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const axios = require("axios");
const nodemailer = require("nodemailer");
const cors = require("cors")({ origin: true });
admin.initializeApp();
const db = admin.firestore();
const bucket = admin.storage().bucket();
// 環境変数から設定を取得
const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN;
const LINE_LOGIN_CHANNEL_ID = process.env.LINE_LOGIN_CHANNEL_ID;
const LIFF_ID = process.env.LIFF_ID;
const LINE_MESSAGING_API_URL = 'https://api.line.me/v2/bot/message/push';
const LINE_PROFILE_API_URL = 'https://api.line.me/v2/profile';
const LINE_VERIFY_TOKEN_URL = 'https://api.line.me/oauth2/v2.1/verify';
exports.config = functions.region('asia-northeast1').https.onRequest((req, res) => {
cors(req, res, () => {
res.json({
LIFF_ID: LIFF_ID
});
});
});
exports.report = functions.region('asia-northeast1').https.onRequest((req, res) => {
cors(req, res, async () => {
if (req.method !== 'POST') {
return res.status(405).send('Method Not Allowed');
}
try {
const rawData = req.body;
console.log('Request data received:', JSON.stringify(rawData));
// 1. データの検証とサニタイズ
const validatedData = validateAndSanitizeData(rawData);
// 2. ユーザー認証
let userId = null;
if (validatedData.accessToken) {
userId = await getUserIdFromAccessToken(validatedData.accessToken);
validatedData.userId = userId;
} else {
throw new Error('アクセストークンが見つかりません。認証が必要です。');
}
// 3. データベース保存 (Firestore & Storage)
const saveResult = await saveToFirestoreAndStorage(validatedData);
// 4. LINE通知
let lineResult = null;
if (userId && LINE_CHANNEL_ACCESS_TOKEN) {
lineResult = await sendLineMessage(userId, validatedData, saveResult);
}
// 5. メール通知
try {
// 宛先リストを取得
const recipientsSnapshot = await db.collection('mail_recipients').get();
const recipients = [];
recipientsSnapshot.forEach(doc => {
const rData = doc.data();
if (rData.email && rData.email.includes('@')) {
recipients.push(rData.email);
}
});
if (recipients.length > 0) {
const gmailConfig = functions.config().gmail;
const gmailEmail = gmailConfig ? gmailConfig.email : null;
const gmailPassword = gmailConfig ? gmailConfig.password : null;
if (gmailEmail && gmailPassword) {
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: gmailEmail,
pass: gmailPassword
}
});
const subject = `【道路通報】新規通報(種別:${validatedData.type})`;
let mailBody = "新しい道路通報がありましたので、お知らせします。\n\n";
mailBody += "----------------------------------------\n";
mailBody += "■ 通報内容\n";
mailBody += "----------------------------------------\n";
mailBody += `・受付日時: ${new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' })}\n`;
mailBody += `・通報種別: ${validatedData.type}\n`;
mailBody += `・詳細: ${validatedData.details || '記載なし'}\n\n`;
mailBody += `・場所の確認(Googleマップ):\n${saveResult.googleMapLink}\n\n`;
if (saveResult.photoUrl) {
mailBody += `・写真の確認:\n${saveResult.photoUrl}\n\n`;
} else {
mailBody += "・写真: なし\n\n";
}
mailBody += "----------------------------------------\n";
// Cloud FunctionsのURLではなく、Firebase HostingのURLを使用する
// プロジェクトIDからHostingのURLを構築(または環境変数で管理しても良いが、今回は簡易的に構築)
const projectId = process.env.GCLOUD_PROJECT || process.env.FIREBASE_CONFIG?.projectId;
const hostingUrl = `https://${projectId}.web.app`;
mailBody += `管理画面: ${hostingUrl}/admin.html\n`;
mailBody += `配信設定: ${hostingUrl}/admin_email.html\n`;
const mailOptions = {
from: `"Road Report App" <${gmailEmail}>`,
to: recipients.join(','),
subject: subject,
text: mailBody
};
await transporter.sendMail(mailOptions);
console.log('Email sent to:', recipients);
} else {
console.log('Gmail config not found. Skipping email.');
}
} else {
console.log('No recipients found. Skipping email.');
}
} catch (mailError) {
console.error('Error sending email:', mailError);
// メール送信失敗しても、通報自体は成功とするためエラーは投げない
}
res.status(200).json({
status: 'success',
message: '通報を受け付けました。ご協力ありがとうございます。',
timestamp: new Date().toISOString(),
id: saveResult.id,
lineNotified: !!lineResult,
imageUploaded: !!saveResult.photoUrl
});
} catch (error) {
console.error('Error processing request:', error);
res.status(500).json({
status: 'error',
message: 'データの処理に失敗しました: ' + error.message
});
}
});
});
function validateAndSanitizeData(rawData) {
const latitude = parseFloat(rawData.latitude);
const longitude = parseFloat(rawData.longitude);
if (isNaN(latitude) || isNaN(longitude) || !rawData.type) {
throw new Error('必須フィールド(緯度、経度、種別)が無効または不足しています。');
}
// photoDataの検証
if (rawData.photoData) {
// サイズチェック (簡易)
if (rawData.photoData.length > 7 * 1024 * 1024) { // Base64で約7MB (元ファイル5MB程度)
throw new Error('画像サイズが大きすぎます。');
}
if (!rawData.photoData.startsWith('data:image/')) {
throw new Error('無効な画像データ形式です。');
}
}
let photoMimeType = null;
if (rawData.photoData) {
photoMimeType = rawData.photoData.substring(5, rawData.photoData.indexOf(';'));
}
return {
latitude,
longitude,
type: sanitizeText(rawData.type),
details: rawData.details ? sanitizeText(rawData.details) : '',
photoData: rawData.photoData || null,
photoMimeType,
accessToken: rawData.accessToken || null
};
}
function sanitizeText(text) {
if (typeof text !== 'string') return text;
return text.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
async function getUserIdFromAccessToken(accessToken) {
try {
// トークン検証
const verifyResponse = await axios.get(`${LINE_VERIFY_TOKEN_URL}?access_token=${accessToken}`);
if (verifyResponse.data.client_id !== LINE_LOGIN_CHANNEL_ID) {
throw new Error('チャネルIDが一致しません。');
}
// プロフィール取得
const profileResponse = await axios.get(LINE_PROFILE_API_URL, {
headers: { 'Authorization': 'Bearer ' + accessToken }
});
return profileResponse.data.userId;
} catch (error) {
console.error('Authentication error:', error.response ? error.response.data : error.message);
throw new Error('ユーザー認証に失敗しました。');
}
}
async function saveToFirestoreAndStorage(data) {
try {
let photoUrl = '';
let storagePath = '';
// 写真保存
if (data.photoData && data.photoMimeType) {
const base64Data = data.photoData.split(',')[1];
const buffer = Buffer.from(base64Data, 'base64');
const filename = `reports/${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
const file = bucket.file(filename);
await file.save(buffer, {
metadata: { contentType: data.photoMimeType },
public: true // 公開設定 (必要に応じて変更)
});
photoUrl = file.publicUrl();
storagePath = filename;
}
const googleMapLink = `https://www.google.com/maps/search/?api=1&query=${data.latitude},${data.longitude}`;
// Firestore保存
const docRef = await db.collection('reports').add({
timestamp: admin.firestore.FieldValue.serverTimestamp(),
status: '未処理', // デフォルトステータス
latitude: data.latitude,
longitude: data.longitude,
googleMapLink,
type: data.type,
details: data.details,
photoUrl,
storagePath,
userId: data.userId
});
return {
id: docRef.id,
photoUrl,
googleMapLink
};
} catch (error) {
console.error('Database/Storage error:', error);
throw new Error('データの保存に失敗しました。');
}
}
async function sendLineMessage(userId, reportData, saveResult) {
try {
const messages = [];
// Flex Message
messages.push(createFlexMessage(reportData, saveResult.photoUrl));
// Location Message
messages.push({
type: 'location',
title: '通報場所',
address: `緯度: ${reportData.latitude}, 経度: ${reportData.longitude}`,
latitude: reportData.latitude,
longitude: reportData.longitude
});
// Image Message
if (saveResult.photoUrl) {
messages.push({
type: 'image',
originalContentUrl: saveResult.photoUrl,
previewImageUrl: saveResult.photoUrl
});
}
// Text Message
messages.push({
type: 'text',
text: createLineTextMessage(reportData, saveResult.googleMapLink, saveResult.photoUrl)
});
await axios.post(LINE_MESSAGING_API_URL, {
to: userId,
messages: messages
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
}
});
return true;
} catch (error) {
console.error('LINE Messaging API error:', error.response ? error.response.data : error.message);
// LINE送信失敗はメイン処理のエラーとしない
return false;
}
}
function createFlexMessage(data, photoUrl) {
return {
type: 'flex',
altText: '道路異状通報を受け付けました',
contents: {
type: 'bubble',
header: {
type: 'box',
layout: 'vertical',
contents: [
{ type: 'text', text: '🚧 道路異状通報', weight: 'bold', color: '#ffffff', size: 'lg' },
{ type: 'text', text: '受付完了', color: '#ffffff', size: 'sm' }
],
backgroundColor: '#3498db',
paddingAll: 'lg'
},
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'box',
layout: 'vertical',
contents: [
{ type: 'text', text: '受付日時', color: '#666666', size: 'sm' },
{ type: 'text', text: new Date().toLocaleString('ja-JP'), weight: 'bold', size: 'md', margin: 'xs' }
],
margin: 'md'
},
{
type: 'box',
layout: 'vertical',
contents: [
{ type: 'text', text: '通報種別', color: '#666666', size: 'sm' },
{ type: 'text', text: data.type, weight: 'bold', size: 'md', margin: 'xs', color: '#e74c3c' }
],
margin: 'md'
},
{
type: 'box',
layout: 'vertical',
contents: [
{ type: 'text', text: '詳細情報', color: '#666666', size: 'sm' },
{ type: 'text', text: data.details || '記載なし', size: 'md', margin: 'xs', wrap: true }
],
margin: 'md'
}
]
},
footer: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'button',
style: 'primary',
action: {
type: 'uri',
label: '🗺️ 地図で確認',
uri: `https://www.google.com/maps?q=${data.latitude},${data.longitude}`
},
color: '#27ae60'
}
],
margin: 'md'
}
}
};
}
function createLineTextMessage(data, mapLink, photoLink) {
const timestamp = new Date().toLocaleString('ja-JP', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
let message = `📋 通報詳細\n\n`;
message += `🔸 種別: ${data.type}\n`;
message += `🔸 詳細: ${data.details || '記載なし'}\n`;
message += `🔸 受付日時: ${timestamp}\n\n`;
if (mapLink) {
message += `📍 場所の確認:\n${mapLink}\n\n`;
}
if (photoLink) {
message += `📷 写真の確認:\n${photoLink}\n\n`;
}
message += `📍 通報を受け付けました。\n`;
message += `ご協力ありがとうございました。`;
return message;
}
admin_email.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>メール配信先管理 - 道路不具合通報</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
margin-bottom: 20px;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: flex-end;
}
.form-group {
flex: 1;
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn-add {
background-color: #2ecc71;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-add:hover {
background-color: #27ae60;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th,
td {
padding: 12px;
border-bottom: 1px solid #eee;
text-align: left;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
.btn-delete {
background-color: #e74c3c;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.btn-delete:hover {
background-color: #c0392b;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #3498db;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
<!-- Firebase SDKs -->
<script defer src="/__/firebase/10.7.1/firebase-app-compat.js"></script>
<script defer src="/__/firebase/10.7.1/firebase-auth-compat.js"></script>
<script defer src="/__/firebase/10.7.1/firebase-firestore-compat.js"></script>
<script defer src="/__/firebase/init.js"></script>
<script defer src="auth.js?v=7"></script>
<script defer src="admin_email.js?v=7"></script>
</head>
<body>
<div class="container">
<a href="admin.html" class="back-link"><i class="fas fa-arrow-left"></i> 通報一覧に戻る</a>
<h2>メール配信先管理</h2>
<p>ここで登録されたメールアドレスに、通報があった際に通知メールが送信されます。</p>
<div class="form-row">
<div class="form-group">
<label for="name">氏名</label>
<input type="text" id="name" placeholder="例: 山田 太郎">
</div>
<div class="form-group">
<label for="email">メールアドレス</label>
<input type="email" id="email" placeholder="例: user@example.com">
</div>
<button class="btn-add" onclick="addRecipient()">追加</button>
</div>
<table>
<thead>
<tr>
<th>氏名</th>
<th>メールアドレス</th>
<th style="width: 80px;">操作</th>
</tr>
</thead>
<tbody id="recipient-list">
<tr>
<td colspan="3" style="text-align: center;">読み込み中...</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
admin_email.js
// 認証後に呼び出される関数
window.startAdminApp = function (user) {
console.log('Starting email admin app for:', user.email);
loadRecipients();
};
async function loadRecipients() {
// タイムアウト設定(10秒)
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('読み込みがタイムアウトしました。')), 10000)
);
try {
const db = firebase.firestore();
// Firestoreの取得とタイムアウトを競走させる
const snapshot = await Promise.race([
db.collection('mail_recipients').orderBy('name').get(),
timeoutPromise
]);
const tbody = document.getElementById('recipient-list');
tbody.innerHTML = '';
if (snapshot.empty) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">登録された宛先はありません</td></tr>';
return;
}
snapshot.forEach(doc => {
const data = doc.data();
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(data.name)}</td>
<td>${escapeHtml(data.email)}</td>
<td>
<button class="btn-delete" onclick="deleteRecipient('${doc.id}', '${escapeHtml(data.name)}')">削除</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (error) {
console.error("Error loading recipients:", error);
alert('読み込みに失敗しました: ' + error.message);
}
}
async function addRecipient() {
const nameInput = document.getElementById('name');
const emailInput = document.getElementById('email');
const name = nameInput.value.trim();
const email = emailInput.value.trim();
if (!name || !email) {
alert('氏名とメールアドレスを入力してください');
return;
}
if (!email.includes('@')) {
alert('正しいメールアドレスを入力してください');
return;
}
try {
const db = firebase.firestore();
await db.collection('mail_recipients').add({
name: name,
email: email,
createdAt: firebase.firestore.FieldValue.serverTimestamp()
});
nameInput.value = '';
emailInput.value = '';
loadRecipients(); // リロード
alert('追加しました');
} catch (error) {
console.error("Error adding recipient:", error);
alert('追加に失敗しました: ' + error.message);
}
}
async function deleteRecipient(id, name) {
if (!confirm(`${name} さんを削除してもよろしいですか?`)) {
return;
}
try {
const db = firebase.firestore();
await db.collection('mail_recipients').doc(id).delete();
loadRecipients(); // リロード
} catch (error) {
console.error("Error deleting recipient:", error);
alert('削除に失敗しました: ' + error.message);
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, function (m) {
return {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[m];
});
}
// グローバル関数として公開
window.addRecipient = addRecipient;
window.deleteRecipient = deleteRecipient;
admin.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>道路不具合通報 管理画面</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
/* 管理画面用スタイル */
body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
/* 全体スクロール禁止 */
}
.admin-header {
padding: 10px 20px;
background-color: #3498db;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.admin-header h2 {
margin: 0;
font-size: 20px;
color: white;
border: none;
padding: 0;
}
.refresh-btn {
background: none;
border: 1px solid white;
color: white;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.refresh-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
/* 地図エリア */
.map-container {
height: 40%;
/* 上部40% */
width: 100%;
position: relative;
flex-shrink: 0;
border-bottom: 2px solid #ddd;
}
#admin-map {
width: 100%;
height: 100%;
}
/* テーブルエリア */
.table-container {
flex: 1;
/* 残りの高さ */
overflow: auto;
/* スクロール */
padding: 10px;
background-color: #f8f9fa;
}
table {
width: 100%;
border-collapse: collapse;
background-color: white;
font-size: 14px;
}
th,
td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
vertical-align: middle;
}
th {
background-color: #f1f1f1;
position: sticky;
top: 0;
z-index: 10;
font-weight: bold;
color: #2c3e50;
}
tr:hover {
background-color: #f5f5f5;
}
tr.active {
background-color: #e3f2fd;
}
/* ステータスバッジ */
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
color: white;
text-align: center;
min-width: 60px;
}
.status-unprocessed {
background-color: #e74c3c;
}
.status-processed {
background-color: #2ecc71;
}
.status-select {
padding: 2px;
font-size: 12px;
border-radius: 4px;
border: 1px solid #ccc;
}
/* リンク */
a.map-link {
color: #3498db;
text-decoration: none;
}
a.map-link:hover {
text-decoration: underline;
}
/* サムネイル */
.thumb-img {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
border: 1px solid #ddd;
}
/* ID列 */
.id-cell {
font-family: monospace;
font-size: 11px;
color: #7f8c8d;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* レスポンシブ */
@media (max-width: 768px) {
.map-container {
height: 30%;
}
th,
td {
padding: 6px;
font-size: 12px;
}
.id-cell {
display: none;
}
/* スマホではID非表示 */
}
</style>
<!-- Firebase SDKs -->
<script defer src="/__/firebase/10.7.1/firebase-app-compat.js"></script>
<script defer src="/__/firebase/10.7.1/firebase-auth-compat.js"></script>
<script defer src="/__/firebase/10.7.1/firebase-firestore-compat.js"></script>
<script defer src="/__/firebase/init.js"></script>
<!-- Leaflet -->
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Admin Script -->
<script defer src="auth.js?v=7"></script>
<script defer src="admin.js?v=7"></script>
</head>
<body>
<div class="admin-header">
<h2>通報一覧</h2>
<div>
<a href="admin_email.html" style="color: white; margin-right: 15px; text-decoration: none;">
<i class="fas fa-envelope"></i> メール配信設定
</a>
<button class="refresh-btn" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> 更新
</button>
</div>
</div>
<div class="map-container">
<div id="admin-map"></div>
</div>
<div class="table-container">
<table id="report-table">
<thead>
<tr>
<th style="width: 100px;">ステータス</th>
<th style="width: 140px;">受付日時</th>
<th style="width: 100px;">通報種別</th>
<th>詳細</th>
<th style="width: 150px;">場所 (緯度, 経度)</th>
<th style="width: 60px;">地図</th>
<th style="width: 70px;">写真</th>
<th class="id-cell" style="width: 80px;">ID</th>
</tr>
</thead>
<tbody id="report-list">
<tr>
<td colspan="8" style="text-align: center; color: #777;">読み込み中...</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
admin.js
// 認証後に呼び出される関数
window.startAdminApp = function (user) {
console.log('Starting admin app for:', user.email);
initMap();
loadReports();
};
let map;
let markers = [];
let reports = [];
// 地図の初期化
function initMap() {
if (map) return;
// 日本全体を表示(データ読み込み後に調整)
map = L.map('admin-map').setView([36.2048, 138.2529], 5);
L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
attribution: "地理院タイル(GSI)",
maxZoom: 18
}).addTo(map);
}
// データの読み込み
async function loadReports() {
// タイムアウト設定(10秒)
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('読み込みがタイムアウトしました。ネットワーク接続を確認してください。')), 10000)
);
try {
const db = firebase.firestore();
// Firestoreの取得とタイムアウトを競走させる
const snapshot = await Promise.race([
db.collection('reports').limit(100).get(),
timeoutPromise
]);
const tbody = document.getElementById('report-list');
tbody.innerHTML = ''; // クリア
if (snapshot.empty) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center;">データがありません</td></tr>';
return;
}
const bounds = L.latLngBounds();
snapshot.forEach(doc => {
const data = doc.data();
const id = doc.id;
reports.push({ id, ...data });
// テーブル行作成
const tr = createTableRow(id, data);
tbody.appendChild(tr);
// マーカー作成
if (data.latitude && data.longitude) {
const marker = L.marker([data.latitude, data.longitude])
.addTo(map)
.bindPopup(createPopupContent(data));
marker.reportId = id;
markers.push(marker);
bounds.extend([data.latitude, data.longitude]);
// マーカークリックイベント
marker.on('click', () => {
highlightTableRow(id);
});
}
});
// 全マーカーが入るようにズーム調整
if (markers.length > 0) {
map.fitBounds(bounds, { padding: [50, 50] });
}
} catch (error) {
console.error("Error getting documents: ", error);
document.getElementById('report-list').innerHTML =
`<tr><td colspan="8" style="color: red;">エラーが発生しました: ${error.message}</td></tr>`;
}
}
function createTableRow(id, data) {
const tr = document.createElement('tr');
tr.dataset.id = id;
const date = data.timestamp ? new Date(data.timestamp.toDate()).toLocaleString('ja-JP') : '日時不明';
const type = data.type || '不明';
const details = data.details || '';
const lat = data.latitude ? data.latitude.toFixed(6) : '-';
const lng = data.longitude ? data.longitude.toFixed(6) : '-';
const googleMapLink = data.googleMapLink || '#';
const photoUrl = data.photoUrl || '';
const status = data.status || '未処理';
// ステータス選択肢
const statusOptions = `
<select class="status-select" onchange="updateStatus('${id}', this.value)">
<option value="未処理" ${status === '未処理' ? 'selected' : ''}>未処理</option>
<option value="処理済" ${status === '処理済' ? 'selected' : ''}>処理済</option>
</select>
`;
// 写真リンク
let photoHtml = '-';
if (photoUrl) {
photoHtml = `<a href="${photoUrl}" target="_blank"><img src="${photoUrl}" class="thumb-img" loading="lazy" alt="写真"></a>`;
}
// Google Mapリンク
let mapLinkHtml = '-';
if (data.googleMapLink) {
mapLinkHtml = `<a href="${data.googleMapLink}" target="_blank" class="map-link"><i class="fas fa-map-marker-alt"></i> Map</a>`;
}
tr.innerHTML = `
<td>${statusOptions}</td>
<td>${date}</td>
<td>${type}</td>
<td>${details}</td>
<td>${lat}, ${lng}</td>
<td>${mapLinkHtml}</td>
<td>${photoHtml}</td>
<td class="id-cell" title="${id}">${id}</td>
`;
tr.addEventListener('click', (e) => {
// インタラクティブ要素(セレクト、リンク)のクリックは無視
if (['SELECT', 'A', 'IMG', 'I'].includes(e.target.tagName)) return;
focusOnMap(id, data.latitude, data.longitude);
highlightTableRow(id);
});
return tr;
}
function createPopupContent(data) {
const date = data.timestamp ? new Date(data.timestamp.toDate()).toLocaleString('ja-JP') : '日時不明';
const status = data.status || '未処理';
let content = `<b>${data.type}</b> <span style="font-size:12px; color:${status === '処理済' ? 'green' : 'red'}">(${status})</span><br>${date}<br>${data.details || ''}`;
if (data.photoUrl) {
content += `<br><img src="${data.photoUrl}" style="width:100%; max-width:200px; margin-top:5px; border-radius:4px;">`;
}
if (data.googleMapLink) {
content += `<br><a href="${data.googleMapLink}" target="_blank">Google Mapで見る</a>`;
}
return content;
}
// グローバル関数として定義(HTMLから呼ぶため)
window.updateStatus = async function (id, newStatus) {
try {
const db = firebase.firestore();
await db.collection('reports').doc(id).update({
status: newStatus
});
// 簡易的にトースト表示(本来はライブラリなど使うと良い)
// alert('ステータスを更新しました');
// リロードせず、行の色を変えるなどの処理だけでも良いが、今回はシンプルに
// 行のスタイル更新(未処理/処理済の色分けなどあれば)
// 今回はセレクトボックスの値が変わるだけなので特になし
console.log('Status updated to ' + newStatus);
} catch (error) {
console.error("Error updating status: ", error);
alert('更新に失敗しました: ' + error.message);
}
};
function highlightTableRow(id) {
// 全てのactiveクラスを削除
document.querySelectorAll('tr').forEach(item => {
item.classList.remove('active');
});
// 指定されたIDの行をactiveにする
const target = document.querySelector(`tr[data-id="${id}"]`);
if (target) {
target.classList.add('active');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function focusOnMap(id, lat, lng) {
if (lat && lng) {
map.setView([lat, lng], 16);
const marker = markers.find(m => m.reportId === id);
if (marker) {
marker.openPopup();
}
}
}
// 実行
auth.js
// 認証ロジックとログインUIの管理
// ログインオーバーレイを即座に作成・表示(Firebase初期化前でも)
createLoginOverlay();
showLoginOverlay();
document.addEventListener('DOMContentLoaded', function () {
// Firebaseの初期化を待つ
const checkFirebase = setInterval(() => {
if (typeof firebase !== 'undefined' && firebase.apps && firebase.apps.length > 0) {
clearInterval(checkFirebase);
initAuth();
}
}, 100);
});
let isAppStarted = false;
function initAuth() {
const auth = firebase.auth();
// 認証状態の監視
auth.onAuthStateChanged(user => {
if (user) {
// ログイン済み
console.log('Logged in as:', user.email);
hideLoginOverlay();
showLogoutButton(user.email);
// メインアプリの開始(各ページで定義されている関数を呼ぶ)
// 二重起動防止
if (window.startAdminApp && !isAppStarted) {
isAppStarted = true;
window.startAdminApp(user);
} else if (!window.startAdminApp) {
console.error('window.startAdminApp is not defined! admin.js may not be loaded correctly.');
alert('管理画面のプログラム読み込みに失敗しました。ページを再読み込みしてください。');
}
} else {
// 未ログイン
console.log('Not logged in');
isAppStarted = false;
showLoginOverlay();
hideLogoutButton();
}
});
}
function createLoginOverlay() {
// すでに存在すれば何もしない
if (document.getElementById('login-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'login-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
`;
const loginBox = document.createElement('div');
loginBox.style.cssText = `
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
text-align: center;
`;
loginBox.innerHTML = `
<h2 style="margin-bottom: 20px; color: #333;">管理者ログイン</h2>
<div style="margin-bottom: 15px; text-align: left;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">メールアドレス</label>
<input type="email" id="login-email" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">
</div>
<div style="margin-bottom: 20px; text-align: left;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">パスワード</label>
<input type="password" id="login-password" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">
</div>
<button id="login-btn" style="width: 100%; padding: 12px; background-color: #3498db; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer;">ログイン</button>
<p id="login-error" style="color: #e74c3c; margin-top: 15px; display: none;"></p>
`;
overlay.appendChild(loginBox);
document.body.appendChild(overlay);
// イベントリスナー
const loginBtn = document.getElementById('login-btn');
const emailInput = document.getElementById('login-email');
const passwordInput = document.getElementById('login-password');
const errorMsg = document.getElementById('login-error');
async function handleLogin() {
const email = emailInput.value;
const password = passwordInput.value;
loginBtn.disabled = true;
loginBtn.textContent = 'ログイン中...';
errorMsg.style.display = 'none';
try {
await firebase.auth().signInWithEmailAndPassword(email, password);
// 成功すれば onAuthStateChanged が発火してオーバーレイが消える
} catch (error) {
console.error('Login error:', error);
errorMsg.textContent = 'ログインに失敗しました: ' + error.message;
errorMsg.style.display = 'block';
loginBtn.disabled = false;
loginBtn.textContent = 'ログイン';
}
}
loginBtn.addEventListener('click', handleLogin);
// Enterキーでもログイン
passwordInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleLogin();
});
}
function showLoginOverlay() {
const overlay = document.getElementById('login-overlay');
if (overlay) overlay.style.display = 'flex';
}
function hideLoginOverlay() {
const overlay = document.getElementById('login-overlay');
if (overlay) overlay.style.display = 'none';
}
function showLogoutButton(email) {
// ヘッダー内のログアウトボタンコンテナを探す(なければ作る)
let logoutContainer = document.getElementById('logout-container');
if (!logoutContainer) {
// admin-headerの中に挿入を試みる
const header = document.querySelector('.admin-header');
if (header) {
logoutContainer = document.createElement('div');
logoutContainer.id = 'logout-container';
logoutContainer.style.cssText = 'display: flex; align-items: center; margin-left: auto;';
// 既存の要素(更新ボタンなど)の前に挿入するか、末尾に追加するか
// ここでは末尾に追加(flexなので右端に行くはず)
header.appendChild(logoutContainer);
}
}
if (logoutContainer) {
logoutContainer.innerHTML = `
<span style="margin-right: 10px; font-size: 12px;">${email}</span>
<button onclick="firebase.auth().signOut()" style="background: none; border: 1px solid white; color: white; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
<i class="fas fa-sign-out-alt"></i> ログアウト
</button>
`;
}
}
function hideLogoutButton() {
const logoutContainer = document.getElementById('logout-container');
if (logoutContainer) {
logoutContainer.innerHTML = '';
}
}
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>道路の不具合通報フォーム</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- LIFF SDK追加 -->
<script defer charset="utf-8" src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script defer src="script.js"></script>
</head>
<body>
<!-- ローディング画面 -->
<div id="loader" class="loader-overlay hidden">
<div class="loader"></div>
<p id="loader-text" class="loader-text">処理中です...</p>
</div>
<!-- メインコンテンツ -->
<div class="container">
<form id="report-form" novalidate>
<h2>道路の不具合を通報</h2>
<!-- LINE連携状態表示 -->
<div id="line-status" class="line-status hidden">
<div class="line-status-content">
<i class="fab fa-line line-icon"></i>
<span id="line-status-text">LINE連携を確認中...</span>
</div>
</div>
<div class="form-group">
<label class="form-label">1. 通報内容</label>
<div class="radio-group">
<div class="radio-item">
<input type="radio" id="type1" name="type" value="雑草" required>
<label for="type1">雑草</label>
</div>
<div class="radio-item">
<input type="radio" id="type2" name="type" value="倒木">
<label for="type2">倒木</label>
</div>
<div class="radio-item">
<input type="radio" id="type3" name="type" value="路面の穴ぼこ・段差">
<label for="type3">路面の穴ぼこ・段差</label>
</div>
<div class="radio-item">
<input type="radio" id="type4" name="type" value="落下物・汚れ">
<label for="type4">落下物・汚れ</label>
</div>
<div class="radio-item">
<input type="radio" id="type-other" name="type" value="その他">
<label for="type-other">その他</label>
</div>
</div>
</div>
<div class="form-group">
<label for="details" class="form-label">
詳細(100文字まで)
<span class="label-note" id="details-required-note"></span>
</label>
<textarea id="details" name="details" rows="4" placeholder="例:直径20cm程度の穴ぼこ" maxlength="100"></textarea>
</div>
<div class="form-group">
<label for="photo">3. 現場の写真 (任意)</label>
<div class="photo-controls">
<label for="photo" class="button-like-input">
<i class="fas fa-file-image"></i> ファイルを選択
</label>
<input type="file" id="photo" name="photo" accept="image/*" style="display: none;">
</div>
<img id="image-preview" src="#" alt="写真プレビュー"/>
</div>
<div class="form-group">
<label>4. 現場の場所</label>
<div id="map-wrapper">
<div id="map"></div>
<i id="center-pin" class="fas fa-map-marker-alt"></i>
</div>
<div id="coords-display">地図を動かして位置を合わせてください</div>
<input type="hidden" id="latitude" name="latitude" required>
<input type="hidden" id="longitude" name="longitude" required>
<!-- LINEアクセストークン用の隠しフィールド -->
<input type="hidden" id="accessToken" name="accessToken">
<input type="hidden" id="userId" name="userId">
</div>
<button type="submit" id="btn-submit" disabled>不具合の種類を選択してください</button>
</form>
</div>
</body>
</html>
Firebase のデプロイ
npx firebase deploy
Authentication の導入

- ”構築”→”Authentication”をクリック

- ”始める”をクリック

- 任意にログイン方法を選択(必要に応じメールアドレス等を登録)
Hosting

- ”構築”→”Hosting”をクリック

- "始める"をクリック

- 表示された手順どおりにコマンドを実行する。
うまくいかないときは明示的にプロジェクトを指定してみる。
- $ firebase projects:list

- $ firebase use 作成したprojectIDに置き換え



- .firebasercファイルの値がプロジェクトIDと一致しているか確認する。
LINE公式アカウント等作成
LINEプロバイダー作成
- LINE developeアクセス(上のリンクをクリック)

- 右上の”コンソール”をクリック

- ”作成”をクリック

- ”+新規チャンネル作成”をクリック

- ”LINEログイン”をクリック
- 必要事項を入力
- アプリタイプは”ウェブアプリ”
- ”作成”をクリック
- ”MessagingAPI"をクリック
- ”LINE公式アカウントを作成する”をクリック
- 必要事項を入力し”完了”をクリック
- ”LINE Official Managerへ”をクリック
- ”同意”等をクリックして進める
- ”ホーム画面へ移動”をクリック

- 右上の”設定”をクリック

- ”MessagingAPI”をクリック
- ”MessagingAPIを利用する”をクリック
- 作成したプロバイダーを選択し”同意する”をクリック
- 何も入力せず”OK”をクリック
- ”OK"をクリック
LINEリッチメニュー作成

- 作成した”MessagingAPI"をクリック(筆者は”作業班dev"と命名)

- ”LINE Official Account Manager"をクリック

- ”ホーム”タブをクリック
- ”リッチメニュー”をクリック

- 作成をクリック

- ”メニュー”の上部エリアをクリック

- 左下の添付レートを選択
- ”選択”をクリック

- ”タイプ”は”リンク”を選択

- firebaseでhostingしたwebsiteのURLをコピー

- コピーしたURLをペースト
- ”保存”をクリック

- LINE Developers にアクセス
- LINE login の LIFF タブをクリック

- ”LIFFアプリ名”を任意に設定
- ”サイズ”はFull
- ”エンドポイントURL”欄に貼り付け
- "Scope"は"profile"を選択
- ”友達追加オプション”は”On(normal)"を選択
- ”追加”をクリック

- 作成したLINEログインチャンネルの”LIFF”タブをクリック
- LIFF ID をコピー

- ”プロパティ”に LINE_CHANNEL_ACCESS_TOKEN と入力
- LINE Messaging API を開く
- Messaging API設定 タブを開く
- チャンネルアクセストークン(長期) をコピーする
- ”値”にペースト

- ”プロパティ”に LINE_LOGIN_CHANNEL_ID と入力
- ”LINEログイン” チャンネルを開く
- ”チャンネル基本設定”タブを開く
- ”チャンネルID”をコピー
- ”値”にペースト

- ”プロパティ”に LINE_LOGIN_CHANNEL_SECRET と入力
- ”チャンネル基本設定”タブを開く
- ”チャンネルシークレット” をコピー
- ”値”にペースト

- ”プロパティ”に LINE_TO_ID と入力
- ”チャンネル基本設定”タブを開く
- ”あなたのユーザーID” をコピー
- ”値”にペースト
QRコード配布

- LINE MessagingAPIを開く
- QRコード上で右クリックしてメニューを開く
- 画像を保存を選択
- QRコードを配布
Discussion