メインコンテンツまでスキップ

学園祭出店申請システム 仕様書

バージョン: 1.1.0 作成日: 2026-04-04 対象: discord.js v14 + TypeScript / Cloudflare Workers + Hono + D1


1. システム概要

1.1 目的

学園祭における出店・発表企画の申請受付から承認までを、Discordのみで完結させるシステム。

1.2 登場人物

役割説明
申請者出店・発表を希望する団体の代表または担当者
実行委員(管理者)申請の審査・承認・却下を行うロール保有者

1.3 システムの特徴

  • 1サーバー完結 — 申請者サーバーに実行委員も参加し、全操作を同サーバー内で行う
  • Components v2統一 — すべての表示UIにComponents v2(MessageFlags.IsComponentsV2)を使用
  • 中断再開対応 — 申請途中でも企画専用チャンネルが作成され、そこから再開可能
  • 参加形式別分岐 — ステージ / 講堂 / 屋外露店 / 講義棟 の4形式で詳細入力が異なる
  • API経由でDB保存 — DiscordボットはCloudflare Workers上のHono APIを経由してD1(SQLite)にデータを保存

1.4 システム構成図

[Discord Bot(Node.js)]
│ Hono RPC(hcクライアント)
│ Bearer Token 認証

[Cloudflare Workers(Hono)]
│ Drizzle ORM

[Cloudflare D1(SQLite)]

2. 技術スタック

2.1 Discordボット側

項目採用技術
ランタイムNode.js 20+
言語TypeScript 5+
Discordライブラリdiscord.js v14.16+
APIクライアントHono RPC hc クライアント
PDF生成pdf-lib + @pdf-lib/fontkit
パッケージマネージャpnpm

2.2 APIサーバー側(Cloudflare Workers)

項目採用技術
ランタイムCloudflare Workers
フレームワークHono
DBCloudflare D1(SQLite)
ORMDrizzle ORM
認証Bearer Token(APIキー)
デプロイWrangler
パッケージマネージャpnpm

2.3 必要な discord.js バージョン

Components v2(ContainerBuilder, TextDisplayBuilder, SectionBuilder 等)は discord.js v14.16+ が必要。

pnpm add discord.js@latest

2.4 必要な Intents / Permissions

const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});

Bot権限(サーバーで必要):

  • Manage Channels — チャンネル作成・移動
  • Manage Roles — ロール作成・付与
  • Send Messages
  • Read Message History
  • Attach Files

3. Discordサーバー構成

3.1 カテゴリ構成

📁 【申請】
└─ #申請-開始 ← 申請開始ボタンを常設するチャンネル

📁 【未受理】 ← 基礎情報申請中・承認待ち企画のチャンネルを配置
└─ #[企画名]-申請 ← 団体名 Modal 送信時に自動作成

📁 【ステージ】 ← ステージ企画が承認後に移動
📁 【講堂】 ← 講堂企画が承認後に移動
📁 【屋外露店】 ← 屋外企画が承認後に移動
📁 【講義棟】 ← 講義棟企画が承認後に移動

📁 【管理】 ← 実行委員専用(管理者ロールのみ閲覧可)
└─ #管理-ログ ← ボット操作ログ
└─ #管理-コマンド ← 管理コマンド実行専用

3.2 ロール設計

ロール名付与タイミング用途
学祭実行委員手動(管理者が設定)承認/却下/修正依頼操作権限
[企画名]-メンバー基礎情報承認時に自動作成・付与企画チャンネルアクセス権
ステージ参加基礎情報承認時(ステージの場合)自動付与形式別識別
講堂参加基礎情報承認時(講堂の場合)自動付与形式別識別
屋外露店参加基礎情報承認時(屋外の場合)自動付与形式別識別
講義棟参加基礎情報承認時(講義棟の場合)自動付与形式別識別

3.3 チャンネル権限設定

企画チャンネル(#[企画名]-申請)作成時の権限:

対象閲覧書き込み
@everyone
[企画名]-メンバー ロール
学祭実行委員 ロール

4. データ設計(Cloudflare D1 + Drizzle ORM)

4.1 テーブル一覧

テーブル名概要
applications申請メタ情報・ステータス管理
basic_info基礎情報(団体・代表者・申請者・企画)
detail_stageステージ詳細情報
detail_hall講堂詳細情報
detail_outdoor屋外露店詳細情報
detail_classroom講義棟詳細情報
equipment_requests貸出備品申請(全形式共通)
members参加メンバーリスト
configサーバー設定(締め切り・カテゴリID等)

4.2 Drizzle スキーマ定義

// api/src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const applications = sqliteTable('applications', {
id: text('id').primaryKey(), // UUID
guildId: text('guild_id').notNull(),
channelId: text('channel_id').notNull(),
roleId: text('role_id').notNull(),
applicantUserId: text('applicant_user_id').notNull(),
status: text('status').notNull(), // ApplicationStatus
basicMessageId: text('basic_message_id'),
detailMessageId: text('detail_message_id'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});

export const basicInfo = sqliteTable('basic_info', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
organizationName: text('organization_name').notNull(),
organizationNameKana: text('organization_name_kana').notNull(),
representativeName: text('representative_name').notNull(),
representativeNameKana: text('representative_name_kana').notNull(),
representativeStudentId: text('representative_student_id'),
representativePhone: text('representative_phone').notNull(),
representativeEmail: text('representative_email').notNull(),
isSameAsRepresentative: integer('is_same_as_representative', { mode: 'boolean' }).notNull(),
applicantName: text('applicant_name'),
applicantNameKana: text('applicant_name_kana'),
applicantStudentId: text('applicant_student_id'),
applicantPhone: text('applicant_phone'),
applicantEmail: text('applicant_email'),
planName: text('plan_name').notNull(),
planDescription: text('plan_description').notNull(),
participationType: text('participation_type').notNull(), // stage|hall|outdoor|classroom
participationDay: text('participation_day').notNull(), // saturday|sunday|both
agreementFileUrl: text('agreement_file_url').notNull(),
agreementFileName: text('agreement_file_name').notNull(),
approvedAt: text('approved_at'),
approvedBy: text('approved_by'),
rejectedAt: text('rejected_at'),
rejectedBy: text('rejected_by'),
revisionComment: text('revision_comment'),
});

// ステージ詳細
export const detailStage = sqliteTable('detail_stage', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
presentationMinutes: integer('presentation_minutes').notNull(),
powerRequest: text('power_request').notNull(),
layoutFileUrl: text('layout_file_url').notNull(),
layoutFileName: text('layout_file_name').notNull(),
membersFileUrl: text('members_file_url').notNull(),
membersFileName: text('members_file_name').notNull(),
remarks: text('remarks'),
approvedAt: text('approved_at'),
approvedBy: text('approved_by'),
rejectedAt: text('rejected_at'),
rejectedBy: text('rejected_by'),
revisionComment: text('revision_comment'),
});

// 講堂詳細(detailStageと同構造。備品の違いはequipment_requestsで管理)
export const detailHall = sqliteTable('detail_hall', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
presentationMinutes: integer('presentation_minutes').notNull(),
powerRequest: text('power_request').notNull(),
layoutFileUrl: text('layout_file_url').notNull(),
layoutFileName: text('layout_file_name').notNull(),
membersFileUrl: text('members_file_url').notNull(),
membersFileName: text('members_file_name').notNull(),
remarks: text('remarks'),
approvedAt: text('approved_at'),
approvedBy: text('approved_by'),
rejectedAt: text('rejected_at'),
rejectedBy: text('rejected_by'),
revisionComment: text('revision_comment'),
});

// 屋外露店詳細
export const detailOutdoor = sqliteTable('detail_outdoor', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
powerRequest: text('power_request').notNull(),
hasGasRequest: integer('has_gas_request', { mode: 'boolean' }).notNull(),
gasStoveCount: integer('gas_stove_count'),
gasHasIgniterLoan: integer('gas_has_igniter_loan', { mode: 'boolean' }),
gasOtherFireEquipment: text('gas_other_fire_equipment'),
gasRemarks: text('gas_remarks'),
hasSales: integer('has_sales', { mode: 'boolean' }).notNull(),
salesFileUrl: text('sales_file_url'),
salesFileName: text('sales_file_name'),
hasFood: integer('has_food', { mode: 'boolean' }).notNull(),
foodIngredients: text('food_ingredients'),
foodCookingProcess: text('food_cooking_process'),
foodCookingProcessUrl: text('food_cooking_process_url'),
foodUsesCarTransport: integer('food_uses_car_transport', { mode: 'boolean' }),
layoutFileUrl: text('layout_file_url').notNull(),
layoutFileName: text('layout_file_name').notNull(),
membersFileUrl: text('members_file_url').notNull(),
membersFileName: text('members_file_name').notNull(),
remarks: text('remarks'),
approvedAt: text('approved_at'),
approvedBy: text('approved_by'),
rejectedAt: text('rejected_at'),
rejectedBy: text('rejected_by'),
revisionComment: text('revision_comment'),
});

// 講義棟詳細
export const detailClassroom = sqliteTable('detail_classroom', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
powerRequest: text('power_request').notNull(),
hasSales: integer('has_sales', { mode: 'boolean' }).notNull(),
salesFileUrl: text('sales_file_url'),
salesFileName: text('sales_file_name'),
hasFood: integer('has_food', { mode: 'boolean' }).notNull(), // 既成品のみ
// レイアウト希望はJSON文字列で保存
// [{ position: "full"|"front"|"back", layoutFileUrl, layoutFileName }]
layoutPreferences: text('layout_preferences').notNull(),
membersFileUrl: text('members_file_url').notNull(),
membersFileName: text('members_file_name').notNull(),
remarks: text('remarks'),
approvedAt: text('approved_at'),
approvedBy: text('approved_by'),
rejectedAt: text('rejected_at'),
rejectedBy: text('rejected_by'),
revisionComment: text('revision_comment'),
});

// 貸出備品申請(全形式共通)
export const equipmentRequests = sqliteTable('equipment_requests', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
equipmentName: text('equipment_name').notNull(),
quantity: integer('quantity').notNull(),
});

// 参加メンバーリスト
export const members = sqliteTable('members', {
id: text('id').primaryKey(),
applicationId: text('application_id').notNull()
.references(() => applications.id),
studentId: text('student_id').notNull(),
name: text('name').notNull(),
});

// サーバー設定
export const config = sqliteTable('config', {
guildId: text('guild_id').primaryKey(),
categoryApplicationEntry: text('category_application_entry').notNull(),
categoryPending: text('category_pending').notNull(),
categoryStage: text('category_stage').notNull(),
categoryHall: text('category_hall').notNull(),
categoryOutdoor: text('category_outdoor').notNull(),
categoryClassroom: text('category_classroom').notNull(),
categoryManagement: text('category_management').notNull(),
roleAdmin: text('role_admin').notNull(),
roleStage: text('role_stage').notNull(),
roleHall: text('role_hall').notNull(),
roleOutdoor: text('role_outdoor').notNull(),
roleClassroom: text('role_classroom').notNull(),
channelApplicationStart: text('channel_application_start').notNull(),
channelManagementLog: text('channel_management_log').notNull(),
startMessageId: text('start_message_id'),
deadline: text('deadline'), // ISO8601
limitWhiteboardTotal: integer('limit_whiteboard_total').notNull().default(1),
limitPartitionOutdoor: integer('limit_partition_outdoor').notNull().default(3),
limitStoveMax: integer('limit_stove_max').notNull().default(2),
limitProjectorMax: integer('limit_projector_max').notNull().default(1),
updatedAt: text('updated_at').notNull(),
});

5. API設計(Cloudflare Workers + Hono RPC)

5.1 認証

全エンドポイントで Bearer Token 認証を行う。

// api/src/middleware/auth.ts
app.use('*', async (c, next) => {
const auth = c.req.header('Authorization');
if (auth !== `Bearer ${c.env.API_SECRET}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
});

ボット側の .env:

API_BASE_URL=https://your-worker.your-account.workers.dev
API_SECRET=your-secret-key

CF Workers シークレット(wrangler secret put API_SECRET で設定)。

5.2 ルート定義と AppType export

// api/src/routes/index.ts
import { Hono } from 'hono';
import { applications } from './applications';
import { config } from './config';

const app = new Hono<{ Bindings: Env }>();

const routes = app
.route('/applications', applications)
.route('/config', config);

export type AppType = typeof routes;
export default app;

5.3 エンドポイント一覧

Applications

メソッドパス説明
POST/applications申請新規作成(Step1完了時)
GET/applications申請一覧取得(status / type フィルタ可)
GET/applications/:id申請1件取得
PATCH/applications/:id/statusステータス更新
PUT/applications/:id/basic基礎情報更新(部分更新可)
PUT/applications/:id/detail詳細情報更新(参加形式別)
GET/applications/:id/membersメンバー一覧取得
PUT/applications/:id/membersメンバー一括更新
GET/applications/:id/equipment備品申請取得
PUT/applications/:id/equipment備品申請一括更新

Config

メソッドパス説明
GET/config/:guildIdサーバー設定取得
PUT/config/:guildIdサーバー設定更新(/setup 時)
PATCH/config/:guildId/deadline締め切り日時更新

5.4 Hono RPCクライアント(ボット側)

// bot/src/lib/api.ts
import { hc } from 'hono/client';
import type { AppType } from '../../../api/src/routes';

const client = hc<AppType>(process.env.API_BASE_URL!, {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`,
},
});

export default client;

使用例:

// 申請新規作成
const res = await client.applications.$post({
json: { guildId, channelId, roleId, applicantUserId },
});
const { id } = await res.json();

// ステータス更新
await client.applications[':id'].status.$patch({
param: { id: applicationId },
json: { status: 'approved_basic', approvedBy: userId },
});

// 基礎情報更新(部分更新)
await client.applications[':id'].basic.$put({
param: { id: applicationId },
json: basicInfoPayload,
});

6. 申請フロー詳細 — Phase 1: 基礎情報

全体の流れ

サーバー参加
→ 申請開始ボタンを押す
→ Step 1: 団体名 Modal
↓ POST /applications → チャンネル・ロール自動作成(未受理カテゴリ)
→ Step 2: 代表者情報 Modal → PUT /applications/:id/basic
→ Step 3: 代表者=申請者?ボタン選択
├─ YES → Step 5 へスキップ
└─ NO → Step 4: 申請者情報 Modal → PUT /applications/:id/basic
→ Step 5: 企画情報(Modal + SelectMenu)→ PUT /applications/:id/basic
→ Step 6: 同意書アップロード(PDFのみ)
→ PUT /applications/:id/basic + PATCH status: pending_basic
→ 企画チャンネルに確認表示 + 承認/却下/修正依頼ボタン

Step 1: 団体名 Modal

トリガー: 申請を開始する ボタン押下

フィールド種別必須
団体名Short
団体名(フリガナ)Short

送信後の処理:

  1. POST /applications で新規エントリ作成(status: "in_progress"
  2. #[団体名]-申請 チャンネルを【未受理】カテゴリに作成
  3. [団体名]-メンバー ロールを作成し、申請者に付与
  4. 企画チャンネルに進行状況メッセージを送信
  5. 代表者情報を入力する ボタンを送信

中断再開: Step 1 完了時点でチャンネルとロールが作成される。申請者がチャンネルを開くとボタンから続きを再開できる。

Step 2: 代表者情報 Modal

フィールド種別必須備考
代表者氏名Short
代表者氏名(フリガナ)Short
学籍番号Short教員の場合は入力不要
電話番号Short
メールアドレスShort

送信後: PUT /applications/:id/basic で代表者フィールドを更新。

Step 3: 代表者=申請者確認

Components v2 ボタンで選択:

  • はい(同一) → Step 5 へ
  • いいえ(別人) → Step 4 へ

Step 4: 申請者情報 Modal(代表者と異なる場合のみ)

フィールド種別必須
申請者氏名Short
申請者氏名(フリガナ)Short
学籍番号Short
電話番号Short
メールアドレスShort

送信後: PUT /applications/:id/basic で申請者フィールドを更新。

Step 5: 企画情報

Modal は最大5項目のため、企画名・詳細は Modal参加形式・参加日は SelectMenu の2段構成。

Step 5-a: 企画名・詳細 Modal

フィールド種別必須
企画名Short
企画詳細・説明Paragraph

Step 5-b: 参加形式・参加日 SelectMenu

Modal 送信後に SelectMenu を表示し「確定する」ボタンで確定。

参加形式の選択肢:

  • ステージ発表
  • 講堂発表
  • 屋外露店
  • 講義棟

参加日の選択肢:

  • 土曜日のみ
  • 日曜日のみ
  • 両日

確定後: PUT /applications/:id/basic で企画情報フィールドを更新。

Step 6: 同意書アップロード

FileUploadBuilder を使用。PDF 以外のファイルはバリデーションで弾く。

提出後の処理:

  1. PUT /applications/:id/basicagreementFileUrl agreementFileName を更新
  2. PATCH /applications/:id/statusstatus: "pending_basic" に更新
  3. 企画チャンネルに確認表示(Components v2)を送信

基礎情報確認表示(管理者向け)

[ContainerBuilder]
[TextDisplayBuilder] 🆕 基礎情報 申請受付
[SeparatorBuilder]
[TextDisplayBuilder] 団体名 / 代表者情報 / 申請者情報 / 企画情報 / 同意書
[SeparatorBuilder]
[ActionRow]
[Button: ✅ 承認 customId: approve_basic:{appId} style: Success]
[Button: ❌ 却下 customId: reject_basic:{appId} style: Danger]
[Button: 📝 修正依頼 customId: revision_basic:{appId} style: Secondary]

ボタンはインタラクション受信時にロールチェックを行い、学祭実行委員 ロール保有者のみ操作可能。

承認処理

  1. ロールチェック
  2. PATCH /applications/:id/statusstatus: "approved_basic"、承認者・日時を記録
  3. 参加形式別ロールを申請者に付与
  4. チャンネルを【未受理】→ 参加形式別カテゴリに移動
  5. ボタンを無効化
  6. 基礎情報確認書 PDF を生成してチャンネルに投稿
  7. Phase 2 開始案内を送信

却下処理

  1. ロールチェック
  2. PATCH /applications/:id/statusstatus: "rejected"
  3. ボタンを無効化、却下通知を送信

修正依頼処理

  1. ロールチェック
  2. コメント入力 Modal を表示
  3. PATCH /applications/:id/statusstatus: "revision_basic"、コメントを保存
  4. コメントをチャンネルに表示 + 再提出する ボタンを送信
  5. 申請者が 再提出する を押すと Step 2 から再入力

7. 申請フロー詳細 — Phase 2: 詳細情報

詳細情報申請開始

基礎情報承認後、チャンネルにロールメンション + 詳細情報を入力する ボタンを送信。

ステージ / 講堂 詳細情報

Step内容API
D1発表時間 SelectMenu(15分刻み)PUT /applications/:id/detail
D2電力申請 Modal同上
D3貸出備品 入力PUT /applications/:id/equipment
D4レイアウト図アップロード(JPEG)PUT /applications/:id/detail
D5参加メンバーリスト CSVアップロードPUT /applications/:id/members
D6その他特記事項 Modal(任意)PUT /applications/:id/detail

ステージ利用可能備品:

備品上限
なし
椅子なし
ホワイトボード(大)大・小合計1
ホワイトボード(小)大・小合計1
小ステージなし

講堂利用可能備品:

備品上限
なし
椅子なし
ホワイトボード(大)大・小合計1
ホワイトボード(小)大・小合計1
長机なし

屋外露店 詳細情報

Step内容API
D1電力申請 ModalPUT /applications/:id/detail
D2ガス申請(ボタンで要/不要選択後 Modal)同上
D3貸出備品 入力(布パーテーション最大3)PUT /applications/:id/equipment
D4販売物有無(あれば販売物詳細表 CSV)PUT /applications/:id/detail
D5食品販売有無(あれば材料表・調理過程・車使用有無)同上
D6レイアウト図アップロード(JPEG)同上
D7参加メンバーリスト CSVアップロードPUT /applications/:id/members
D8その他特記事項 Modal(任意)PUT /applications/:id/detail

ガス申請 Modal(D2):

フィールド必須備考
コンロ数最大2。数値バリデーション
着火器具貸出希望「希望する」「希望しない」
ホットプレート等持込火器持込物(団体自前)である旨を注意書き明記
その他

食品詳細 Modal(D5):

フィールド必須備考
材料表材料名・冷蔵可否・冷蔵方法
調理過程クッキングスタジオ/テント内(加熱のみ)
参考URL
車での移送「使用する」「使用しない」

屋外露店利用可能備品:

備品上限
なし
椅子なし
ホワイトボード(大/小)合計1
布パーテーション3

講義棟 詳細情報

Step内容API
D1電力申請 Modal(火器使用不可を案内)PUT /applications/:id/detail
D2貸出備品 入力(プロジェクターは後方時不可)PUT /applications/:id/equipment
D3販売物有無(食品は既成品配布のみ・材料記入不要)PUT /applications/:id/detail
D4レイアウト希望(第1〜3希望の位置 + 各JPEG)同上
D5参加メンバーリスト CSVアップロードPUT /applications/:id/members
D6その他特記事項 Modal(任意)PUT /applications/:id/detail

レイアウト希望(D4)の位置選択肢:

  • 全体
  • 前方のみ
  • 後方のみ

講義棟利用可能備品:

備品上限備考
なし
椅子なし
ホワイトボード(大/小)合計1
布パーテーションなし
既設プロジェクター1後方希望時は選択不可

詳細情報確認表示と最終承認

全 Step 完了後、チャンネルに参加形式別の確認表示(Components v2)を送信。

[Button: ✅ 承認 customId: approve_detail:{appId} style: Success]
[Button: ❌ 却下 customId: reject_detail:{appId} style: Danger]
[Button: 📝 修正依頼 customId: revision_detail:{appId} style: Secondary]

最終承認処理:

  1. PATCH /applications/:id/statusstatus: "approved"
  2. チャンネルに承認完了メッセージを送信
  3. 詳細情報確認書 PDF を生成してチャンネルに投稿

8. 管理者機能

8.1 権限チェック共通処理

async function isAdmin(interaction: Interaction, guildId: string): Promise<boolean> {
if (!interaction.inGuild()) return false;
const member = interaction.member as GuildMember;
const res = await client.config[':guildId'].$get({ param: { guildId } });
const config = await res.json();
return member.roles.cache.has(config.roleAdmin);
}

8.2 /list コマンド

GET /applications?status=&type= で取得し Components v2 で表示(ephemeral)。

オプション必須説明
statusString(選択式)ステータスでフィルタ
typeString(選択式)参加形式でフィルタ

8.3 /deadline set コマンド

オプション必須
datetimeString(YYYY-MM-DD HH:mm 形式)

処理:

  1. PATCH /config/:guildId/deadline で締め切りを更新
  2. #申請-開始 の案内メッセージを更新(締め切り表示)
  3. 締め切り超過の場合、ボタンを disabled: true に更新

8.4 /timetable コマンド

GET /applications?status=approved で取得し参加形式・参加日ごとにまとめて Components v2 で表示。

8.5 /export csv コマンド

全承認済み企画のメンバーを並列取得し CSV にまとめてファイル添付で返信(ephemeral)。

CSVフォーマット:

企画名,団体名,参加形式,学籍番号,氏名
◯◯ライブ,◯◯サークル,ステージ,12345678,山田 太郎

8.6 /export pdf コマンド

指定企画の確認書 PDF を再出力してチャンネルに投稿(ephemeral)。

8.7 /setup コマンド(初期設定)

初回のみ実行する管理者専用コマンド。

処理:

  1. 各カテゴリ・チャンネル・ロールを自動作成
  2. PUT /config/:guildId で設定を保存
  3. #申請-開始 に申請開始メッセージを送信

9. Components v2 UI仕様

9.1 基本ルール

  • 全メッセージに MessageFlags.IsComponentsV2 を付与
  • Embed は一切使用しない
  • 色分けは ButtonStyle で表現
import { MessageFlags } from 'discord.js';

await interaction.reply({
flags: MessageFlags.IsComponentsV2,
components: [container],
});

9.2 使用コンポーネント一覧

コンポーネント用途
ContainerBuilder全体ラッパー(必須)
TextDisplayBuilderテキスト表示(Markdown サポート)
SectionBuilder項目グルーピング
SeparatorBuilderセクション区切り線
MediaGalleryBuilder画像ファイルプレビュー
ButtonBuilder操作ボタン
StringSelectMenuBuilder選択メニュー
FileUploadBuilderファイルアップロード
ActionRowBuilderボタン/メニューのコンテナ

9.3 ボタンスタイル規約

操作ButtonStyle
承認・確定・進むButtonStyle.Success(緑)
却下・キャンセルButtonStyle.Danger(赤)
修正依頼・戻るButtonStyle.Secondary(グレー)
申請開始・詳細申請ButtonStyle.Primary(青)

9.4 CustomId 命名規則

{action}:{applicationId}:{additionalParam}

例:

approve_basic:uuid-xxxx
reject_basic:uuid-xxxx
revision_basic:uuid-xxxx
approve_detail:uuid-xxxx
next_step:uuid-xxxx:step2
same_as_rep:uuid-xxxx:yes
participation_type:uuid-xxxx

10. PDF出力仕様

10.1 概要

タイミング生成PDF投稿先
基礎情報承認時基礎情報確認書(PDF)企画チャンネル
詳細情報承認時詳細情報確認書(PDF)企画チャンネル

10.2 使用ライブラリ

pnpm add pdf-lib @pdf-lib/fontkit

日本語フォントは Noto Sans JP(OFL-1.1)を assets/ に同梱。

10.3 基礎情報確認書 PDF 構成

ファイル名: 基礎情報確認書_{企画名}_{承認日時}.pdf

[ヘッダー] 学園祭出店申請 基礎情報確認書 / 承認日時 / 承認者
[団体情報] 団体名 / フリガナ
[代表者] 氏名 / フリガナ / 学籍番号 / 電話 / メール
[申請者] 代表者と同一 または 各情報
[企画情報] 企画名 / 参加形式 / 参加日 / 説明
[同意書] ファイル名 / 提出日時

10.4 詳細情報確認書 PDF 構成

ファイル名: 詳細情報確認書_{企画名}_{承認日時}.pdf

共通セクション:

[ヘッダー] 承認日時 / 承認者 / 企画名 / 参加形式
[電力申請] 申請内容
[貸出備品] 備品名・数量の一覧表
[参加メンバーリスト] 学籍番号・氏名の一覧表
[特記事項] (入力がある場合のみ)

参加形式別追加セクション:

  • ステージ / 講堂: 発表時間
  • 屋外露店: ガス申請内容 / 販売物情報 / 食品販売情報
  • 講義棟: 販売物情報 / レイアウト希望(第1〜3希望)

10.5 実装方針

// bot/src/services/pdf.ts
import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { AttachmentBuilder } from 'discord.js';
import * as fs from 'fs/promises';

async function generateBasicInfoPdf(app: ApplicationData): Promise<Buffer> {
const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
const fontBytes = await fs.readFile('assets/NotoSansJP-Regular.ttf');
const font = await pdfDoc.embedFont(fontBytes);
const page = pdfDoc.addPage([595.28, 841.89]); // A4
// ... コンテンツ描画 ...
return Buffer.from(await pdfDoc.save());
}

// チャンネルへの投稿
const pdfBuffer = await generateBasicInfoPdf(app);
const attachment = new AttachmentBuilder(pdfBuffer, {
name: `基礎情報確認書_${planName}_${timestamp}.pdf`,
});
await channel.send({ files: [attachment] });

10.6 フォントファイルの配置

bot/
└── assets/
└── NotoSansJP-Regular.ttf # Google Fonts から取得(OFL-1.1)

11. ファイルアップロード仕様

11.1 アップロード受付フロー

  1. FileUploadBuilder を Components v2 内に配置
  2. ユーザーがファイルを選択し、ボタンで送信
  3. インタラクション受信時に添付ファイルを取得
  4. バリデーション実施
  5. Discord CDN URL を API 経由で D1 に保存

注意: Discord CDN URL には有効期限がある場合がある。長期保存が必要な場合は別途ダウンロード・保存を検討すること。

11.2 バリデーション規則

ファイル許可拡張子 / MIME上限サイズ
同意書.pdf / application/pdf10MB
レイアウト図.jpg .jpeg / image/jpeg10MB
参加メンバーリスト.csv / text/csv5MB
販売物詳細表.csv / text/csv5MB
電力申請CSV.csv / text/csv5MB

12. コマンド一覧

コマンド説明権限
/setup初期セットアップ(カテゴリ・チャンネル・ロール作成)管理者
/list [status] [type]申請一覧表示管理者
/deadline set <datetime>締め切り日時設定管理者
/timetableタイムテーブル表示管理者
/export csv参加者 CSV エクスポート管理者
/export pdf <applicationId>指定企画の確認書 PDF を再出力管理者

13. エラーハンドリング

13.1 共通エラー応答

await interaction.reply({
flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2,
components: [
new ContainerBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent('❌ エラー: ' + message)
)
]
});

13.2 主なエラーケース

エラー対応
締め切り超過での申請ボタン disabled + エラーメッセージ
PDF 以外のファイルアップロード(同意書)エラーメッセージ + 再アップロード促す
JPEG 以外のファイルアップロード(レイアウト図)エラーメッセージ + 再アップロード促す
CSV 不正フォーマットエラーメッセージ + フォーマット例を提示
権限なしでの管理操作ephemeral エラー
ホワイトボード上限超過エラーメッセージ + 修正促す
布パーテーション上限超過(屋外)エラーメッセージ(上限3)
コンロ数が3以上エラーメッセージ(上限2)
後方希望 + プロジェクター選択エラーメッセージ(後方は選択不可)
CF Workers API 呼び出し失敗エラーログ出力 + ephemeral エラー

14. ディレクトリ構成

project-root/
├── bot/ # Discordボット(Node.js)
│ ├── src/
│ │ ├── index.ts
│ │ ├── client.ts
│ │ ├── deploy-commands.ts
│ │ ├── lib/
│ │ │ └── api.ts # Hono RPC hcクライアント
│ │ ├── commands/
│ │ │ ├── setup.ts
│ │ │ ├── list.ts
│ │ │ ├── deadline.ts
│ │ │ ├── timetable.ts
│ │ │ └── export.ts
│ │ ├── interactions/
│ │ │ ├── index.ts
│ │ │ ├── buttons/
│ │ │ │ ├── startApplication.ts
│ │ │ │ ├── approveBasic.ts
│ │ │ │ ├── rejectBasic.ts
│ │ │ │ ├── revisionBasic.ts
│ │ │ │ ├── approveDetail.ts
│ │ │ │ ├── rejectDetail.ts
│ │ │ │ └── revisionDetail.ts
│ │ │ ├── modals/
│ │ │ │ ├── organizationName.ts
│ │ │ │ ├── representative.ts
│ │ │ │ ├── applicant.ts
│ │ │ │ ├── planInfo.ts
│ │ │ │ ├── powerRequest.ts
│ │ │ │ ├── gasRequest.ts
│ │ │ │ ├── foodDetail.ts
│ │ │ │ └── remarks.ts
│ │ │ └── selectMenus/
│ │ │ ├── participationType.ts
│ │ │ ├── participationDay.ts
│ │ │ ├── presentationTime.ts
│ │ │ └── layoutPosition.ts
│ │ ├── builders/
│ │ │ ├── basicInfoConfirm.ts
│ │ │ ├── detailInfoConfirm.ts
│ │ │ ├── progressMessage.ts
│ │ │ ├── startMessage.ts
│ │ │ └── timetableDisplay.ts
│ │ ├── services/
│ │ │ ├── approval.ts
│ │ │ ├── channel.ts
│ │ │ ├── pdf.ts
│ │ │ └── csv.ts
│ │ ├── utils/
│ │ │ ├── validation.ts
│ │ │ ├── permissions.ts
│ │ │ └── customId.ts
│ │ └── types/
│ │ └── index.ts
│ ├── assets/
│ │ └── NotoSansJP-Regular.ttf
│ ├── package.json
│ ├── tsconfig.json
│ └── .env

├── api/ # Cloudflare Workers API
│ ├── src/
│ │ ├── index.ts # Honoエントリーポイント + AppType export
│ │ ├── routes/
│ │ │ ├── applications.ts
│ │ │ └── config.ts
│ │ ├── db/
│ │ │ ├── schema.ts # Drizzle スキーマ定義
│ │ │ └── index.ts # DB接続(drizzle(c.env.DB))
│ │ ├── middleware/
│ │ │ └── auth.ts # Bearer Token認証
│ │ └── types.ts # Env型定義
│ ├── drizzle/
│ │ └── migrations/ # マイグレーションファイル
│ ├── wrangler.toml
│ ├── package.json
│ └── tsconfig.json

└── pnpm-workspace.yaml # モノレポ設定

環境変数(bot/.env):

DISCORD_TOKEN=
CLIENT_ID=
GUILD_ID=
API_BASE_URL=https://your-worker.your-account.workers.dev
API_SECRET=your-secret-key

CF Workers シークレット:

wrangler secret put API_SECRET

api/wrangler.toml の D1 バインディング:

[[d1_databases]]
binding = "DB"
database_name = "festival-db"
database_id = "your-database-id"

15. 備品・制約マスタ

15.1 備品一覧と参加形式別利用可否

備品ステージ講堂屋外講義棟上限
なし
椅子なし
ホワイトボード(大)大・小合計1 ※
ホワイトボード(小)大・小合計1 ※
小ステージなし
長机なし
布パーテーション屋外:3 / 講義棟:なし ※
既設プロジェクター1(後方希望時は選択不可)

※ 上限は変更される可能性あり。config テーブルの limit_* カラムで管理し、コード変更なしで更新できるようにすること。

15.2 上限値の設定場所

config テーブル内のカラム:

カラムデフォルト値説明
limit_whiteboard_total1ホワイトボード大・小の合計上限
limit_partition_outdoor3屋外露店の布パーテーション上限
limit_stove_max2ガスコンロ数の上限
limit_projector_max1既設プロジェクターの上限