🧑‍🎓

【Next.js】文化祭のチケット・レジシステムを作成・運用した話

2024/12/14に公開

2024年度に行った私 (筆者) が所属している学校での文化祭において、全校生徒(高校3年生を除く)および教職員、来場者、そして保護者に対しての文化祭システムを作成・運用させていただきました。今回はこのシステムの作成・運用にあたっての話を書いていきます。

本記事の内容は、私が所属する学校に非公式で公開しているものです。本記事に関する問い合わせを学校へ行う行為は慎んでいただくようお願いいたします。

また、本記事の画像には最新のものでない古い画像があることがあります。

構想

2023年度の文化祭サイトは私とは別の人が作成しており、TypeScript (Node.js) + Express + EJS + Prisma (MySQL) が使用されており、API の構造として GraphQL が使用されていました。
2024年3月に私がサイト開発業務に入って、チームはどのような構造にすべきか話し合っていました。とある人は去年と同じ構造にすべきだと主張していたのですが、私が Express + EJS という技術でやっていける自信がなく (EJS 自体は昔触れていたのですが少々面倒だったなという記憶が残っていた)、少々失礼ではありますが今の時代に EJS ってのもどうなの、とも思っていたため、Next.js を提唱したところ、すんなりと採用していただけました。

そして技術構成は、Next.js (v14) + TailwindCSS + Prisma (MongoDB、ただし後に PostgreSQL に切り替わりました) + TypeScript となりました。

後述するメンバーのスペックにより、何人かのメンバーには Next.js および React を覚えてもらうことになりましたが、最終的にはこのタスクで通用するほどのコードを書けるようになったので、ほっとしました。

(2024年3月当時の) 技術メンバーのスペック

私 (筆者)

  • (Type|Java)Script、Next.js、React ともに書ける
  • Linux の知識も問題ない
  • デザインの知識がない

生徒A

  • (Type|Java)Script、Next.js、React ともに書ける
  • Linux に関しての知識はない

生徒M

  • JavaScript は書けるが、React および Next.js をやったことはない
  • Linux に関しての知識はない

開発

チケットシステム - 取り掛かり

私がまず取り掛かったのはチケットシステムでした。私の学校の文化祭は、一般公開ではなく受験生や生徒の保護者・友人などの限られた人しか入ることができません。今回は保護者・友人
の入場を以下のフローのようにしました。

このシステムを作っていたときはまだ入場係はどうするのか?などの構想が固まっていなかったので、ひとまず全生徒ができるチケットの発行を先に作りました。

権限設定

文化祭のシステムという小規模なものであっても、チケットをスキャンできるのは入場係のみ、全ユーザーを検索できるのは教員と開発者のみなどといったユーザーと権限制御が必要になりました。
そこで、ウィキペディアなどにも使われているウィキシステムである MediaWiki の利用者システムと権限システムに目をつけ、MediaWiki のような権限システムを実装しました。


MediaWiki の権限システムの例 (https://www.mediawiki.org/wiki/Special:ListGroupRights)


グループ管理ページ


ユーザー権限管理ページ

各ページ/Server Actions で権限が必要になる場合、以下の汎用関数を使用することで検知を行いました。

import { Group } from "@prisma/client";

export function canExecute(
  user: { groups: Group[] },
  requiredPermissions: string[],
) {
  const userPermissions = user.groups.flatMap((group) => group.permissions);
  return requiredPermissions.every((permission) =>
    userPermissions.includes(permission),
  );
}
model Group {
  id           String   @id @default(uuid())
  name         String
  permissions  String[]
  undeletable  Boolean  @default(false) @map("undeletable")

  users User[]

  @@map("groups")
}

レジシステム/決済システム

PTA の要望により、2022年頃までは紙を使用して行っていた、生徒への文化祭での販売で使用できる金券配布を電子化することになりました。
金銭を管理するとなるとそれなりに重い責任が生じると感じています。特に決済部分は慎重に実装しなければなりませんでした。
私が権限システムを作っている間に生徒Aがシステムの基盤実装を含んだ Pull Request を出してくれたので、コードレビューを行いながら進めていました。

当日の販売では、3階グラウンドで10種類程度の食品が販売され、これが今回作成するレジシステムの使用可能食品となりました。
どの食品がどの程度売れたかを把握するために、「チーム」(先ほどの権限システムに存在したグループとは違う概念) をあらたに作成しました。
(ただし、別途 manageteam という、チームを作成・管理できる権限とそれを持ったグループは存在しました)


manageteam 権限持ちが見れる全チーム管理画面


この下に棒グラフと円グラフがあります

一つ以上のチームに所属している場合、以下の画面にアクセスできました。
生徒が当日中に他の生徒と販売ブースを交代することを予期し、生徒は複数のチームに所属できました。

当日中、生徒は以下の画面にアクセスし、決済係の生徒に提示しました。

テスト中の画面のため残高がとても多くなっています

決済係の生徒は、QRコードを読み込み、

決済を行いました。

相手に見せるために、金額は反転したものも併せて表示していました

本決済は、以下のようなコードがメインで行われていました。Prisma の transaction 機能を用いて、同時に決済を行うことでお金が増殖する、などがないように対策しています。

export const doHirooPayPayment = async (
  userId: string,
  scannerId: string,
  yen: number,
  currentTeamId: string,
) => {
  try {
    await prisma.$transaction(
      async (transactionalPrisma) => {
        const userData = await transactionalPrisma.user.findUnique({
          where: {
            id: userId,
          },
        });
        if (!userData) {
          throw new Error("user");
        }
        if (userData.cash < yen) {
          throw new Error("short");
        }
        if (yen < 0 || Number.isInteger(yen) === false) {
          throw new Error("invalid");
        }
        await transactionalPrisma.user.update({
          where: {
            id: userId,
          },
          data: {
            cash: userData.cash - yen,
          },
        });
        await transactionalPrisma.transactionLog.create({
          data: {
            userId: userId,
            usermail: userData.email,
            amount: yen,
            type: "hiroo-payment",
            merchant: scannerId,
            teamId: currentTeamId,
          },
        });
        await transactionalPrisma.team.update({
          where: {
            id: currentTeamId,
          },
          data: {
            amount: {
              increment: yen,
            },
          },
        });
        await transactionalPrisma.notification.create({
          data: {
            userId: userId,
            type: "hiroo-payment",
            message: "支払いが完了しました",
            details1: `¥${yen}の支払いが完了しました`,
          },
        });
      },
      {
        isolationLevel: "Serializable",
      },
    );
  } catch (e) {
    if (e === "short") {
      return {
        status: "error1",
      };
    } else if (e === "user") {
      return {
        status: "error2",
      };
    }
    return {
      status: "error2",
    };
  }
  return {
    status: "success",
  };
};

(後述するチケットシステムでも使用される) QRコード読み取りは、react-qr-reader を使用しています。

ログシステム・ユーザー周り

私開発者、および教職員のために、サイト上で行われた行動をトラッキングするログシステムを搭載しました。
(権限のあるユーザーしか見れないので、UI がだいぶ雑になってしまっています)

また、ユーザー検索機能を搭載し、名字や名前、メールアドレス、学年・クラス、また所属グループで検索できるようにしました。

以下のように実装しました。グループ絞り込み用トグルスイッチがすべてOFFの場合に0件になってしまうのを解消するために、動的に where 条件を差し込んでいます。より良い方法がありましたら、コメントで教えて下さい。次年度の参考にさせていただきます。

export const getUsersInformation = async (
  value: string,
  condition: string,
  groupIds: string[],
) => {
  if (condition === "gradeClass") {
    const [grade, classNum] = value.split("-");
    const where: Prisma.UserWhereInput = {
      grade: parseInt(grade),
      class: parseInt(classNum),
    };

    if (groupIds.length > 0) {
      where["AND"] = groupIds.map((groupId) => {
        return {
          groups: {
            some: {
              id: groupId,
            },
          },
        };
      });
    }

    const users = await prisma.user.findMany({
      where,
      take: 50,
    });
    return users.map((user) => {
      return {
        id: user.id,
        name: formatName(user.lastName, user.firstName),
        email: user.email,
        grade: user.grade,
        class: user.class,
        studentNumber: user.studentNumber,
      };
    });
  } else {
    const where: Prisma.UserWhereInput = {
      [condition]: {
        contains: value,
        mode: "insensitive",
      },
    };
    if (groupIds.length > 0) {
      where["AND"] = groupIds.map((groupId) => {
        return {
          groups: {
            some: {
              id: groupId,
            },
          },
        };
      });
    }
    const users = await prisma.user.findMany({
      where,
      take: 50,
    });
    return users.map((user) => {
      return {
        id: user.id,
        name: formatName(user.lastName, user.firstName),
        email: user.email,
        grade: user.grade,
        class: user.class,
        studentNumber: user.studentNumber,
      };
    });
  }
};

チケットシステム - 完成

仕様が固まって、完成したものが以下です。

入場係がチケットを読み込んで、

承認することで、入場処理が完了します。

また、チケットの設定によっては、招待者にメールが送信されます。

export const approveTicket = async (ticketId: string) => {
  const sessionUser = await getSessionUser();
  if (!sessionUser) {
    return failure("ログインしてください");
  }
  const { id } = sessionUser;
  const user = await findUserById(id);
  if (!user) {
    return failure("ユーザーが見つかりません");
  }
  if (!canExecute(user, ["readticket"])) {
    return failure("権限がありません");
  }

  const ticket = await findTicketById(ticketId);
  if (!ticket) {
    return failure("チケットが見つかりません");
  }
  if (ticket.enterAt) {
    return failure("すでに入場済みです");
  }

  const ticketUser = await findUserById(ticket.userId);

  const tasks: Promise<unknown>[] = [
    updateTicket(ticketId, { enterAt: new Date() }),
    createLog("scanticket", {
      executorId: id,
      details1: ticketId,
    }),
    createNotification(ticket.userId, "enter", {
      details1: ticket.customerName,
    }),
  ];

  if (ticket.notify > 0) {
    // ここでメールを送信する
    tasks.push(
      sendMailToUser(
        ticketUser!.email,
        `${ticket.customerName}さんが入場しました`,
        `${ticket.customerName}さんが入場しました。\n\n本メールは、チケット設定で通知を受け取る設定になっているため送信されています。`,
      ),
    );
  }

  await Promise.all(tasks);

  return success(ticket);
};

ディレクトリ構成

最終的なディレクトリ構成はこうなっています。

.
├── .github/ # GitHub 用の CI/CD など
├── .vscode/ # VSCode 用設定
├── prisma/ # Prisma のスキーマ・シードファイル
├── public/ # 画像や音声ファイルなど。なるべく画像は WebP にする
└── src/ # メインコード
    ├── @types/ # 型情報
    ├── app/ # ウェブサイトとして表示される場所
    │   ├── _home/ # トップページのコンポーネント
    │   ├── api/auth/[...nextauth]/ # NextAuthのログイン用
    │   ├── ...
    │   ├── mypage/ # マイページ実装
    │   └── ...
    ├── atoms/ # jotai の atom のまとめ場所
    ├── components/ # コンポーネント。src/ui/ と比べて比較的粒度大きめのものを配置
    ├── constants/ # サイト上から編集する必要性がないと予想される固定された値
    ├── lib/ # 細かな関数の集合体
    ├── repositories/ # prisma でデータベースを操作するためのラッパー関数。全部server-onlyにすること
    └── ui/ # UIコンポーネント。src/componentsと比べて比較的粒度小さめのものを配置

本番デプロイ

誤算

最初はデプロイに Vercel を使おうと思っていたのですが、Pro プランで料金が $20 より増える可能性があるとの噂を聞きつけてしまい、心配性であった私は使うのを取りやめます。(後述しますが、これは別の意味で正解でした)

その後、ホスティングサービスを吟味していたのですが、友人 (私の高校所属ではない) が Linode がいいんじゃないと話してきたので、Linode のプランで価格を見積もり、それを予算申請しました。

しかし、後々学校はクレジットカード決済を受け付けず、銀行振込若しくは請求書払いのみと教職員に通告されました。Linode はどちらも対応していないため、諦めざるを得ませんでした。

ただし、そこまで悪い状況ではありませんでした。なにしろ Linode で見積もった予算はかなり高額になっていたため、他の VPS を検討できました。

結果、Xserver VPSさくらのVPS を使用することになりました。
(分散しているのはどちらかのサーバーが万が一当日にメンテナンスなどにより使えなくなってしまうことへの対策です。後述します。)

契約したサーバーは2会社併せて

  • 16GB x 2 (Next.js メインサーバーと PostgreSQL)
  • 2GB x 3 (ロードバランサー HAProxy、監視用 Prometheus & Grafana、Redis)
    と、 Next.js では考えられないほどオーバースペックだと今は思っています。

2023年度文化祭で、前任者が文化祭当日にサイトを数時間落としてしまったというインシデントを聞いていたので、私はサイトを落ちることに過度な恐怖を抱いていました。

今思えば絶対こんなにいらなかった。予算を無駄にしてごめんなさい。

デプロイ

公開の時が来ました。前任者は Kubernetes や Proxmox などを使用していましたが、あまりに冗長化しても管理が面倒になるだけだと思い、使用しませんでした。

Docker は使用しようと思っていたのですが、Dockerfile の作成中に Prisma がエラーを吐き、数時間格闘したものの上手く行かず、断念し、PostgreSQL の稼働のみに使用しました。

サーバー同士の接続

5台サーバーがありますが、それぞれをグローバル IP でつなげ合うのは危険だと考え、VPN サービスである Tailscale を使用しました。

Next.js キャッシュの問題

Next.js を複数サーバーで動作させロードバランサーで分散させることについてなにか問題がないか調べたところ、キャッシュの問題が浮上しました。
解決策を調べたところ、

  1. インメモリキャッシュを無効にする
  2. ファイルキャッシュを Redis にする
    というものがあったので、そのとおりに実装しました。
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  // ...
  cacheMaxMemorySize: 0,
  cacheHandler:
    process.env.NODE_ENV === "production"
      ? (process.env.HANDLER_PATH ??
        (await import.meta.resolve(
          path.join(__dirname, "./cache-handler.mjs"),
        )))
      : undefined,
  // ...
  },
};

export default nextConfig;

cache-handler.mjs ですが、@neshca/cache-handler という、Next.js のキャッシュを扱いやすくしようというプロジェクトの、公式サイトの例をほぼそのまま使わせてもらっています。

Next.js デプロイ

前述したように Docker が上手く行かなかったため、普通にディレクトリを git clone し、pnpm build を実行して、以下のようにサービス化して起動させました。

[Unit]
Description=2024Keyaki
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/srv/2024Keyaki
ExecStart=/usr/bin/pnpm run start
Restart=always

[Install]
WantedBy=multi-user.target

ロードバランサー (HAProxy)

今回はロードバランサーに HAProxy を採用しました。

cookie SERVERID insert indirect nocache を入れると、アクセス時に Cookie が付与されてその Cookie が有効な間はずっと同じサーバーからレスポンスが帰るようになります。これをセットすると負荷分散の有効度が下がるのですが、HTML ファイルと JavaScript ファイルが別サーバーから帰るなどのリスクを考え、設定しました。

http-request use-service prometheus-exporter if { path /metrics } で、後述する Prometheus 用の exporter を設置しています。

haproxy.cfg
frontend http-in
    bind *:80
    default_backend servers

frontend stats
   bind *:8405
   http-request use-service prometheus-exporter if { path /metrics }
   stats enable
   stats uri /stats
   stats refresh 10s

backend servers
    balance roundrobin
    cookie SERVERID insert indirect nocache
    server keyaki-main-1 keyaki-main-1:3000 check cookie keyaki-main-1
    server keyaki-main-2 keyaki-main-2:3000 check cookie keyaki-main-2

メールサーバー

入場時に招待者にメールを送るという仕様が存在します。最初は Gmail の SMTP を使用しようと考えたのですが、

1 日の送信数の上限(ユーザーごと)* 2,000

- Google Workspace における Gmail の送信制限

とあり、もしかしたら制限を超えてしまうかもしれません。複数のアカウントを用意するという選択肢も考えましたが、美しくありませんし、このメールシステムの要件が届いたのは結構後でありメールアドレスを調達できない可能性のほうが高かったです。かといって他のメール送信サービスを契約する予算を申請する時間はありません。

仕方がないので、今までやったことのなかったメールサーバーの立ち上げに挑戦することにしました。

前から Twitter などで、SPF や DKIM を設定しないと Gmail ユーザーにメールが届かないという噂を聞いていたので、設定し、ついでに DMARC も設定しました。

sudo apt install postfix opendkim opendkim-tools をし、DKIM を設定し、以下のように設定しました。

/etc/postfix/main.cf
milter_protocol = 2
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891

smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = hiroogakuen.net
mydomain = hiroogakuen.net
mydestination = $myhostname, hiroogakuen.net, keyaki-main-1.tail2caa79.ts.net, localhost.tail2caa79.ts.net, localhost
mynetworks = 127.0.0.0/8 100.64.0.0/10 [::ffff:127.0.0.0]/104 [::1]/128
inet_interfaces = all
inet_protocols = ipv4

また、@ に TXT レコードで以下を追加しました。

v=spf1 ip4:ここにサーバーのパブリックIP -all

この設定を行い、nodemailer を使用してメールを送信すると、メールが届いて感動しました!

その他、Prometheus と Grafana もセットアップしましたが、ここでは省略します。

Cloudflare

前述したように、私はサイトが落ちることに恐怖を抱いていたため、サイトのオリジンサーバーに届くトラフィックはなるべく減らさないといけないと考え、Cloudflare の Cache Rules を用いて、なるべく多くのものをキャッシュしました。

  • /_next/image* は、エッジキャッシュ 1日、ブラウザキャッシュ オリジンTTL尊重、クエリ文字列並び替え 有効
  • (http.request.uri.path.extension in {"7z" "avi" "avif" "apk" "bin" "bmp" "bz2" "class" "csv" "doc" "docx" "dmg" "ejs" "eot" "eps" "exe" "flac" "gif" "gz" "ico" "iso" "jar" "jpg" "jpeg" "mid" "midi" "mkv" "mp3" "mp4" "ogg" "otf" "pdf" "pict" "pls" "png" "ppt" "pptx" "ps" "rar" "svg" "svgz" "swf" "tar" "tif" "tiff" "ttf" "webm" "webp" "woff" "woff2" "xls" "xlsx"}) は、エッジキャッシュ 1日、ブラウザキャッシュ 6ヶ月、クエリ文字列並び替え 有効
  • (http.request.uri.path.extension in {"css" "js"}) は、エッジキャッシュ 1日、ブラウザキャッシュ オリジンTTL尊重、クエリ文字列並び替え 有効

ここまでできたら、@ の A レコードをサーバーの IP にセットして、公開しました!

結果

少しトラブルは有りましたが、大成功しました!2日間サイトを一度も落とすことなく、運営することができました。

また、Cloudflare で非常に多くのトラフィックをキャッシュすることができました。

キャッシュされた割合 80.47%

トラブル

金額が引かれていない?

とある生徒から、500円使ったはずなのに、引かれてなかったと申告がありました。
大急ぎでログを確認したところ、この生徒に関する決済ログはなかったので、スキャン失敗したのに食品をあげた人がいると推定しました。
担当教職員に報告したあと、そのようなミスがないように食品販売係に徹底し、管理者権限を用いて500円の引き出しを実行しました。

昼食がない

筆者である私が昼食を受け取り忘れたせいでひもじい思いをしました。

今後の展望

Docker で動くようにする

今回は非常にマニュアルな方法で Next.js アプリをデプロイしていました。Docker 化できれば、Coolify の採用も検討できるため、ぜひとも次年度にやりたいところです。

テストを書く

今回はテストを書くことができませんでした。Vitest などを用いて書けると良かったなと思います。

メールのログ監視

一部からメールが届いていないと報告がありましたが、ログ取りが不十分だったために究明できませんでした。次年度は Graylog などを用いてログを取ることを検討します。

謝辞

私がサイト開発者として円滑に活動できていたのは、学校の皆様の協力のおかげです。
改めて感謝申し上げます。

また、この記事を最後まで読んでくださった方も、ありがとうございました。

Hiroo-Gakuen-ICT-Committee

Discussion