目次
1. はじめに
2. アプリ概要
3. 構成図
4. 使用技術
5. 解説
6. 終わりに
はじめに
はじめまして。前に請求書を送信できる簡単なアプリを作成したのですが、そのアプリに機能を付け加えたり、デザインを修正したのでもう一度記事を書いて見ることにしました。
前回の記事
アプリ概要
LIFFのアプリ内で請求書を作成し、その請求書をLINEの友達に送信できます。
簡易デモ
QRコード
友達追加お願いします!
機能
- LIFFのアプリ内で請求書を作成できる
- その請求書をLINEの友達に送信できる
- 自分が送った請求書の履歴を見ることができる
作成動機
- 家族や友達とお金の貸し借りをよくする
- お金を返してと言いづらいことを解消したい
- 身近なアプリであるLINEでNext.jsを使用したものを作りたかった
- デプロイまでしたかった
Githubリンク
使用技術
フロントエンド バックエンド
- TypeScript (Next.js)
UI
- Tailwind CSS
データベース
- PostgreSQL (Supabase)
ストレージ
- Supabase
ORM
- Prisma
ホスティング
- Vercel
その他
- LIFF(LINEログインチャネル)
- LINE Official Account Manager
- Line Messaging API
- Git
- Github
構成図
解説
ハンコの画像生成API
今回のアプリでは、請求書の画像にLINEのプロフィール写真をハンコ風の画像に変換した画像を載せました。
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import { supabase } from '@/lib/supabaseClient';
import { v4 as uuidv4 } from 'uuid';
export async function POST(req: NextRequest) {
try {
const { profileImageUrl } = await req.json();
const response = await fetch(profileImageUrl);
if (!response.ok) {
throw new Error(`画像の取得に失敗しました: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
(画像処理のコード)
// Supabaseに画像をアップロード
const fileName = `${uuidv4()}.png`;
const { error: uploadError } = await supabase.storage
.from('hanko-images')
.upload(fileName, hankoImage, {
contentType: 'image/png',
});
if (uploadError) {
throw new Error(`Supabaseへの画像アップロードに失敗しました: ${uploadError.message}`);
}
// 画像のパブリックURLを取得
const publicUrlResponse = supabase.storage
.from('hanko-images')
.getPublicUrl(fileName);
if (!publicUrlResponse.data || !publicUrlResponse.data.publicUrl) {
throw new Error('画像のパブリックURLの取得に失敗しました');
}
const publicURL = publicUrlResponse.data.publicUrl;
return new NextResponse(JSON.stringify({ imageUrl: publicURL }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error: unknown) {
console.error('画像処理エラー:', error);
let errorMessage = '画像処理に失敗しました';
if (error instanceof Error) {
errorMessage = error.message;
}
return new NextResponse(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
このAPIでは、画像URLを受け取り、その画像をハンコの画像に変換し、Supabaseのストレージに生成した画像をアップロードしています。そして、アップロードした画像のURLを取得しクライアントに返しています。
最初はBase64を使用してURLを作成しましたがURLが長すぎたので、Supabaseを利用しました。多分もっといい方法があると思います。
請求書にLINEのプロフィール画像のハンコを載せることで、固いイメージのある請求書に少しだけユーモアを出すことができました。
請求書の画像生成API
今回のアプリでは、ユーザーからの入力に応じて請求書の画像を動的に生成したいので、Next.jsの機能である Image Generation を利用してAPIを作成しました。
参考記事
import { NextRequest } from "next/server";
import { ImageResponse } from "@vercel/og";
export const runtime = "edge";
export function GET(req: NextRequest) {
if (req.method !== "GET") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
const { searchParams } = new URL(req.url);
const issueDate = searchParams.get("issueDate") || "";
const dueDate = searchParams.get("dueDate") || "";
const amount = searchParams.get("amount") || "";
const message = searchParams.get("message") || "";
const recipient = searchParams.get("recipient") || "";
const hankoImage = searchParams.get("hankoImage") || "";
const decodedHankoImage = decodeURIComponent(hankoImage);
console.log(hankoImage);
return new ImageResponse(
(略)
);
} catch (e: unknown) {
if (e instanceof Error) {
console.log(`${e.message}`);
} else {
console.log("An unknown error occurred");
}
return new Response("画像の生成に失敗しました", {
status: 500,
});
}
}
例えばこのような画像が生成されます。
履歴ページ
送信した内容を見ることができる履歴ページを作成しました。また、ユーザーが精算済みと未精算を切り替えられたり、履歴を消したりすることができるようにしました。データベースはSupabaseのPostgreSQLを使用し、ORMにPrismaを使用しています。
データベース設計
ユーザーの情報はLIFF経由で都度LINEプラットフォームから取得しています。
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'ユーザーIDが必要です' }, { status: 400 });
}
const invoices = await prisma.invoice.findMany({
where: { userId },
orderBy: { sentDate: 'desc' },
});
return NextResponse.json(invoices);
}
export async function POST(request: Request) {
try {
const body = await request.json();
console.log('Received invoice data:', body);
if (!body.userId || body.userId.trim() === '') {
return NextResponse.json({ error: 'userId is required and cannot be empty' }, { status: 400 });
}
if (!body.recipient || body.recipient.trim() === '') {
return NextResponse.json({ error: 'recipient is required and cannot be empty' }, { status: 400 });
}
if (typeof body.amount !== 'number' || isNaN(body.amount) || body.amount < 0) {
return NextResponse.json({ error: 'amount must be a valid number greater than or equal to 0' }, { status: 400 });
}
if (!body.dueDate || isNaN(Date.parse(body.dueDate))) {
return NextResponse.json({ error: 'dueDate must be a valid date' }, { status: 400 });
}
if (typeof body.message !== 'string') {
return NextResponse.json({ error: 'message must be a string' }, { status: 400 });
}
const invoice = await prisma.invoice.create({
data: {
userId: body.userId,
recipient: body.recipient,
amount: body.amount,
dueDate: new Date(body.dueDate),
message: body.message,
}
});
return NextResponse.json(invoice);
} catch (error) {
console.error('Error creating invoice:', error);
return NextResponse.json({ error: 'Failed to create invoice' }, { status: 500 });
}
}
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(request: Request, { params }: { params: { id: string } }) {
const id = parseInt(params.id);
const invoice = await prisma.invoice.findUnique({
where: { id },
});
if (!invoice) {
return NextResponse.json({ error: '請求書が見つかりません' }, { status: 404 });
}
return NextResponse.json(invoice);
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
const id = parseInt(params.id);
const body = await request.json();
const updatedInvoice = await prisma.invoice.update({
where: { id },
data: body,
});
return NextResponse.json(updatedInvoice);
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
const id = parseInt(params.id);
await prisma.invoice.delete({ where: { id } });
return NextResponse.json({ message: '請求書が正常に削除されました。' });
}
履歴画面です。
LIFF連携
LINEアプリ内での使用を想定しているので、作るWebアプリとLIFFアプリを連携します。
LIFFアプリを作成するためにLINEログインチャネルを作成します。(LINEログインとLINEミニアプリ以外のチャネルにはLIFFアプリを追加できないみたいです。)
https://developers.line.biz/console/ にアクセスし、LINEアカウントでログインし、プロバイダーを作成し、そのプロバイダーの中にLINEログインチャネルを作成します。
ログインチャネルを作成したら、「LIFF」タブからLIFFアプリを作成します。
エンドポイントURLにNext.jsで作ったアプリのURLを設定します。
そもそもLIFF連携をしていないとユーザー情報の取得ができないので、この設定は最初にする必要があります。
LINE公式アカウントの作成
https://manager.line.biz/ にアクセスし公式LINEアカウントを作成し、作成したLIFFアプリを埋め込みます。今回はリッチメニューから作成したLIFFアプリにアクセスできるようにしました。
終わりに
初めて使用した技術を使うこともあり、いろいろ苦戦しましたが、なんとかアプリを形にすることができてよかったです。
インターンやハッカソンでのチーム開発では細かなデザインについては他のメンバーに任せて詳しく触れることがなかったので、今回のアプリ開発で学べることができてよかったです。Tailwind CSSはAIに聞きながら開発しやすく便利でした。
はじめてLINEのアプリを作成してみて、さまざまな機能があることを知り、使いこなせるともっと面白くて便利なアプリを作成できると思いました。