学園祭出店申請システム 仕様書
バージョン: 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 |
| DB | Cloudflare D1(SQLite) |
| ORM | Drizzle 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 MessagesRead Message HistoryAttach 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 | ✅ |
送信後の処理:
POST /applicationsで新規エントリ作成(status: "in_progress")#[団体名]-申請チャンネルを【未受理】カテゴリに作成[団体名]-メンバーロールを作成し、申請者に付与- 企画チャンネルに進行状況メッセージを送信
代表者情報を入力するボタンを送信
中断再開: 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 以外のファイルはバリデーションで弾く。
提出後の処理:
PUT /applications/:id/basicでagreementFileUrlagreementFileNameを更新PATCH /applications/:id/statusでstatus: "pending_basic"に更新- 企画チャンネルに確認表示(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]
ボタンはインタラクション受信時にロールチェックを行い、
学祭実行委員ロール保有者のみ操作可能。
承認処理
- ロールチェック
PATCH /applications/:id/status→status: "approved_basic"、承認者・日時を記録- 参加形式別ロールを申請者に付与
- チャンネルを【未受理】→ 参加形式別カテゴリに移動
- ボタンを無効化
- 基礎情報確認書 PDF を生成してチャンネルに投稿
- Phase 2 開始案内を送信
却下処理
- ロールチェック
PATCH /applications/:id/status→status: "rejected"- ボタンを無効化、却下通知を送信
修正依頼処理
- ロールチェック
- コメント入力 Modal を表示
PATCH /applications/:id/status→status: "revision_basic"、コメントを保存- コメントをチャンネルに表示 +
再提出するボタンを送信 - 申請者が
再提出するを押すと 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 | 電力申請 Modal | PUT /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]
最終承認処理:
PATCH /applications/:id/status→status: "approved"- チャンネルに承認完了メッセージを送信
- 詳細情報確認書 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)。
| オプション | 型 | 必須 | 説明 |
|---|---|---|---|
status | String(選択式) | ❌ | ステータスでフィルタ |
type | String(選択式) | ❌ | 参加形式でフィルタ |
8.3 /deadline set コマンド
| オプション | 型 | 必須 |
|---|---|---|
datetime | String(YYYY-MM-DD HH:mm 形式) | ✅ |
処理:
PATCH /config/:guildId/deadlineで締め切りを更新#申請-開始の案内メッセージを更新(締め切り表示)- 締め切り超過の場合、ボタンを
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 コマンド(初期設定)
初回のみ実行する管理者専用コマンド。
処理:
- 各カテゴリ・チャンネル・ロールを自動作成
PUT /config/:guildIdで設定を保存#申請-開始に申請開始メッセージを送信
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 アップロード受付フロー
FileUploadBuilderを Components v2 内に配置- ユーザーがファイルを選択し、ボタンで送信
- インタラクション受信時に添付ファイルを取得
- バリデーション実施
- Discord CDN URL を API 経由で D1 に保存
注意: Discord CDN URL には有効期限がある場合がある。長期保存が必要な場合は別途ダウンロード・保存を検討すること。
11.2 バリデーション規則
| ファイル | 許可拡張子 / MIME | 上限サイズ |
|---|---|---|
| 同意書 | .pdf / application/pdf | 10MB |
| レイアウト図 | .jpg .jpeg / image/jpeg | 10MB |
| 参加メンバーリスト | .csv / text/csv | 5MB |
| 販売物詳細表 | .csv / text/csv | 5MB |
| 電力申請CSV | .csv / text/csv | 5MB |
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_total | 1 | ホワイトボード大・小の合計上限 |
limit_partition_outdoor | 3 | 屋外露店の布パーテーション上限 |
limit_stove_max | 2 | ガスコンロ数の上限 |
limit_projector_max | 1 | 既設プロジェクターの上限 |