#patchwork2022 の運営側を色々システム化したあれこれ

# はじめに

先日2022/8/13に、渋谷 clubasia にて #patchwork2022 というイベントを開催しました。
話の始まりは2019年ぐらいからだったので、実に3年越しの開催ができ感無量でした。
ご来場頂いた皆様、配信で見てくださった皆様、本当にありがとうございました。

さて、この度patchworkの内部では色々と作業の効率化施策をやってきました。
イベント開催という一区切りがついたタイミングなので、一旦文章化しようと思いこの記事を書きます。

大規模なイベントを企画している方々には色々課題を持ってる方も多いと思うので、参考になる部分がございましたら嬉しいです。
基本的に無料でできるようなものを選定しているので、触れる人さえいれば導入のハードルはそんなに高くないかなと思います。

# Discord の導入

前身のイベントみくるサマーではトピックごとにLINEグループを作成してそこでやり取りしていましたが、扱う情報の種類、量が多くなることは確実であり、PCからのアクセスの悪さの解消も兼ねて早めのタイミングでDiscordに移行しました。
他アプリとの連携ができたりエモートをつけるだけで手軽にコミュニケーションとったりでき、後述のいろいろな施策の軸となったためこれはやってよかったなと思います。

また、アクセス権を制御すれば外部のメンバーも同じサーバーに招待できるため、やり取りをDiscordの中で完結できるメリットもありました。
個別にメールやLINE、DMで連絡をとることは手軽なのですが、メンバー全員に伝達するコストは別途かかります。やり取りする相手がDiscordに使い慣れているのであれば、メンバー全員が確認できるDiscordでやり取りをすることでどうなってるかを共有できるのは良かったかなと思います。


# ホームページ関連

patchwork の広報としては基本的にはTwitterアカウントを使用していますが、Twitterだけではどうしても情報が流れてしまいがちです。ある程度情報をまとめる場所としてホームページを作成しました。

[patchwork official site](https://patchwork-prj.netlify.app/) 

## 採用技術

技術スタックとしては

- Next.js
- tailwindcss
- material-ui

辺りを採用して、 Netlify にデプロイしています。
本当は Vercel にデプロイしてみたかったのですが、無料プランでは非商用利用のみでしか使えないという制約があり採用を見送りました。patchworkは営利プロジェクトではないですが、一応チケットの前売りの動線を設定したりするので金銭のやり取りは発生するし、、、このあたりどこまでが線引きなのか判断できなかったので

また当初は静的なサイトだけの予定だったのでHUGOとかで作ろうとしてましたが、テンプレートをいじれる気がまったくしなかったので公開ちょっと前にNext.jsに切り替えてイチから作り直しました。結果的には後述のISRを使ったりできたのでこの選択は大正解でした。

最初の公開の時にiOSでのテストが十分にできてなく表示がボコボコになってしまったのは反省です。いつだって敵はSafari

## 開発フロー

内部的にはこんなフローで開発していて、main, staging, developでそれぞれデプロイ環境を用意しました。
それぞれ、

- main：本番ホームページ
- staging：メンバー確認用
- develop：開発者（僕）確認用

Netlifyの設定次第では一つのデプロイ環境だけで実現可能なのですが、後述の通知とのかみ合わせが悪く最終的にはそれぞれの環境ごとにプロジェクトを分けてデプロイしてます。ここは若干イケてないな～とは思います。
![hoge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641384/90d1729b-ba40-1214-86b3-dd14e9254de8.png)

## 公募結果のHPへのリアルタイム反映

今回のHPで割と気合を入れて作った機能の一つが、公募に応募されたDJ Mixをリアルタイムでホームページに反映させる機能でした。
どのように実現したかをざっくりまとめると

1. GoogleFormで投稿された情報をSpreadsheetに出力
1. Spreadsheetに紐づいたGoogleAppsScriptを作成して、WebAPIとしてSpreadsheetの内容を取得するAPIを作成
2. Next.js の ISR でMix情報をサーバーサイドで取得し、Mixを列挙したページをサーバーサイドでレンダリング
   - ISR の revalidate を設定することでページアクセスの都度GoogleAppsScriptにアクセスしないようにした

といった感じです。
ちゃんと書くと長くなりそうですがその割に複雑なことはやってないので需要があればもう少し詳しく書こうかなという感じです。一部ソースは折り畳みに入れてきます
ISRってあんまり嬉しさがわかってなかったですが、今回のように

1. そんなに頻繁にデータ更新されない
1. APIのコールをたくさんしたくない（GoogleAppsScriptは無料だけど実行回数に制限がある）

といったパターンにはバッチリはまるな～と実感できました



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641384/156819be-3d6b-b51d-0abd-e55081b8ba5f.png)


この機能は施策としても面白かったですが、それ以上に公募選出のタイミングでスタッフ側でMixを聞くときにも非常に役立ったので作ってよかったなと思います。
すごいMixがたくさん投稿されているのでぜひ聞いてください
[plus one応募 Mix | patchwork official site](https://patchwork-prj.netlify.app/plus-one/mix)

<details>
<summary>Spreadsheet から情報を取得するGAS</summary>

```fetchPlusOneInfo.ts
import Const from "./const";
import { domains } from "./domain";
import { MixInfo } from "./type";

const doGet = () => {
  const activeSheet = SpreadsheetApp.getActiveSheet();

  const lastRow = activeSheet.getLastRow();

  // タイムスタンプ
  const timeRange = activeSheet.getRange(
    2,
    Const.SHEET_COLUMN_TIMESTAMP,
    lastRow
  );
  const timeValue = timeRange
    .getValues()
    .flat()
    .map((el) => new Date(el));

  // 名前
  const nameRange = activeSheet.getRange(2, Const.SHEET_COLUMN_NAME, lastRow);
  const nameValue = nameRange.getValues().flat() as string[];

  // url
  const urlRange = activeSheet.getRange(2, Const.SHEET_COLUMN_URL, lastRow);
  const urlValue = urlRange.getValues().flat() as string[];

  // フロア
  const floorRange = activeSheet.getRange(2, Const.SHEET_COLUMN_FLOOR, lastRow);
  const floorValue = floorRange.getValues().flat() as string[];

  const preResponse: MixInfo[] = nameValue.map((_, i) => {
    return {
      date: timeValue[i],
      name: nameValue[i],
      floor: domains.getFloorType(floorValue[i]),
      url: urlValue[i],
      type: domains.getUrlType(urlValue[i]),
    };
  });

  // 月は -1
  const deadEnd = new Date(2022, 5 - 1, 14);
  const response = preResponse.filter(
    (el) => el.floor !== "unknown" && el.type !== "unknown" && el.date < deadEnd
  );

  console.log(response);

  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(
    ContentService.MimeType.JSON
  );
};
```

</details>

<details>
<summary>APIから取得してサーバーサイドでMix一覧をレンダリングするページ</summary>

```mix.tsx
import React from "react";
import { Page } from "components/layout";
import { MixInfo } from "types";
import { MixFrame } from "components/molecules";

export const getStaticProps = async () => {
  const res = await fetch(
    API_URL,
  );
  const mixInfo = (await res.json()) as MixInfo[];

  return {
    props: { mixInfo },
    revalidate: 3600,
  };
};

type Props = {
  mixInfo?: MixInfo[];
};

const PlusOneMix: React.FC<Props> = ({ mixInfo: plusOneMixInfo }) => {
  const mainMix = plusOneMixInfo.filter(el => el.floor === "main");
  const secondMix = plusOneMixInfo.filter(el => el.floor === "second");
  const loungeMix = plusOneMixInfo.filter(el => el.floor === "lounge");

  return (
    <Page
      title="plus one応募 Mix"
      description="DJ 公募企画 『plus one』に応募されたMixを紹介します"
    >
      <article className="">
        <section>
          <h1>エントリー公募紹介</h1>
        </section>

        <section className="pb-6">
          <h2 className="sticky top-0 w-auto bg-secondary text-gray-50 text-2xl py-2 z-20">
            1F メインフロア
          </h2>
          {mainMix?.map(el => <MixFrame key={el.url} url={el.url} type={el.type} />)}
        </section>

        <section className="pb-6">
          <h2 className="sticky top-0 w-auto bg-secondary text-gray-50 text-2xl py-2 z-20">
            2F セカンドフロア
          </h2>
          {secondMix?.map(el => <MixFrame key={el.url} url={el.url} type={el.type} />)}
        </section>

        <section className="pb-6">
          <h2 className="sticky top-0 w-auto bg-secondary text-gray-50 text-2xl py-2 z-20">
            1F ラウンジ
          </h2>
          {loungeMix?.map(el => <MixFrame key={el.url} url={el.url} type={el.type} />)}
        </section>
      </article>
    </Page>
  );
};

export default PlusOneMix;
```
</details>

<details>
<summary>soundCloud / mixcloudの埋め込みMixを表示するUIコンポーネント</summary>

```MixFrame.tsx
import { Card } from "@material-ui/core";
import { MixURLType } from "types";

type Props = { url: string; type: MixURLType };

export const MixFrame: React.FC<Props> = ({ url, type }) => {
  return (
    <Card className="my-2">
      {type === "mixcloud" ? (
        <iframe
          width="100%"
          height="120"
          src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&feed=${url}`}
          frameBorder="0"
        ></iframe>
      ) : (
        <iframe
          width="100%"
          height="130"
          scrolling="no"
          frameBorder="no"
          src={`https://w.soundcloud.com/player/?url=${url}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=true&show_user=true&show_reposts=false&show_teaser=true`}
        ></iframe>
      )}
    </Card>
  );
};
```

</details>

# 各種通知

前述のDiscordを導入したので、それぞれ専用のチャンネルを設けて通知をDiscordに送るようにしました。
色々やった結果最終的に以下を通知に流してます。

1. Googleカレンダーの予定リマインド（1日1回今日明日の予定を通知）
1. 演者プロフィールや公募Mixの投稿情報
1. HPのデプロイ通知
1. ハッシュタグのパブサ

## Googleカレンダー予定リマインド

ミーティングの予定の共有はメンバー内で共有しているGoogleカレンダーで共有していました。しかし、ミーティングの予定など（主に僕が）そんなに覚えてられないのでデイリーでリマインドするようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641384/1f947af8-f10f-d2d6-8a25-004c7281b193.png)

clasp + typescript で作成した GoogleAppsScriptを毎朝デイリーで動かすようにしています。ずいぶん前に書いたので何も覚えてないですが、そんなに難しいことはしてないと思います。
GoogleAppsScript をデイリーで動かすのは [公開不可な Spotify プレイリストの内容をなんとかして公開した話 - Qiita](https://qiita.com/tainakanchu/items/b0adf710bcd69b76ee9e) でも扱ってますね。

GoogleAppsScriptはサクッと単機能のものを作成するのに本当に便利ですね。この記事のこの後でもいろいろ使ってます。

<details>
<summary>Googleカレンダーの予定リマインド</summary>

```dailyCalendarNotification.ts
/**
 * 今日と明日の予定を投稿する (GASのエントリーポイント)
 **/
const notifyTasksToDiscord = (): void => {
  notifyTaskToDiscord("今日", 0);
  notifyTaskToDiscord("明日", 1);
};

/**
 *  指定日の予定をDiscordに投稿する
 */
const notifyTaskToDiscord = (when: string, dayOffset: number): void => {
  const taskList = taskOfDayList(CALENDAR_ID, dayOffset);

  if (taskList.length) {
    const tasks = taskList
      .map((task) => {
        return "・" + task + "\n";
      })
      .join();

    const payload: DiscordWebhookPayload = {
      username: "予定を確認してくれるキルトちゃん",
      content:
        `@everyone \n${when}は予定があります！忘れないでよね！！\n\n` + tasks,
    };
    postDiscord(payload);
  }
};

/**
 * イベントのリストを取得
 * @param cal_id イベントを取得するGoogleカレンダーのID
 * @param dayOffset 日付の違い +1 ->  1日後
 * @returns  イベント名のリスト
 */
const taskOfDayList = (cal_id: string, dayOffset: number): string[] => {
  const cal = CalendarApp.getCalendarById(cal_id);
  const date = new Date();
  date.setDate(date.getDate() + dayOffset);
  const events = cal.getEventsForDay(date);

  // 取得できたリストを整理する
  return events.map((event) => {
    // イベントのタイトル
    const title = event.getTitle();

    // 終日の予定の時はタイトルだけ
    if (event.isAllDayEvent()) {
      return title;
    }
    // 開始時間がある時は開始時間を付記
    else {
      // HH:mm 時間を取得
      const start = event.getStartTime();
      const time =
        zeroPadding(start.getHours(), 2) +
        ":" +
        zeroPadding(start.getMinutes(), 2);

      return time + "〜 " + title;
    }
  });
};

/**
 * Discordに投稿
 * @param payload Discordに投稿する内容
 */
const postDiscord = (
  payload: DiscordWebhookPayload
): GoogleAppsScript.URL_Fetch.HTTPResponse => {
  const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
  };

  const url = WEBHOOK_URL;
  const response = UrlFetchApp.fetch(url, options);
  console.log(response.getContentText("UTF-8"));
  return response;
};

/**
 * 数値をゼロ埋めして文字列にする
 * @param value ゼロ埋め対象の値
 * @param length ゼロ埋めしたい桁数
 * @returns ゼロ埋めした文字列
 */
const zeroPadding = (value: number, length: number): string => {
  return (Array(length).join("0") + value).slice(-length);
};
```

</details>


## 演者プロフィールや公募Mixの投稿情報

演者のプロフィール文や公募MixはGoogleFormで回収していましたが、その投稿をSpreadsheetに追加するのと同時にDiscordに投げるようにしました。
これによってGoogleFormやSpreadsheetを見に行かなくてもDiscordのみで投稿状況がわかり、円滑な情報共有ができたのかなと思っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641384/e5012f76-f27d-6ed4-5b18-b6449aaca579.png)

実装としてはこちらもカレンダーの通知と似たような感じで、GoogleFormに紐づけたGoogleAppsScriptを投稿時に発火するように設定し、その結果をDiscordにぶん投げてます。
以下は公募MixのForm投稿をDiscrodに投げる処理です。

<details>
<summary>演者プロフィール投稿をDiscordに通知する</summary>

```plusOneFormNotification.ts
import Const from "./const";
import { ExtractResponse, FloorType, PostMixInfo } from "./form";
import { Webhook } from "discord-webhook-ts";

type SendToDiscordProps = {
  body: string;
  isErrorNotification?: boolean;
};

/**
 * Webhook の POST を送信するハンドラ
 *
 * @param body 送信するメッセージの文字列
 */
const sendToDiscord = ({
  body,
  isErrorNotification = false,
}: SendToDiscordProps) => {
  const url = isErrorNotification
    ? Const.ERROR_NOTIFICATION_WEBHOOK_URL
    : Const.WEBHOOK_URL;

  const data: Webhook.input.POST = {
    content: body,
  };
  const payload = JSON.stringify(data);
  const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
    method: "post",
    contentType: "application/json",
    payload: payload,
  };
  const response = UrlFetchApp.fetch(url, options);
  console.log(response.getResponseCode());
};

const onFormSubmit = (e: GoogleAppsScript.Events.FormsOnFormSubmit) => {
  try {
    const itemResponses = e.response.getItemResponses();

    const responses: ExtractResponse[] = itemResponses.map((formData) => ({
      key: formData.getItem().getTitle(),
      value: joinStringArray(formData.getResponse()),
    }));

    console.log(JSON.stringify(responses, null, 2));

    const postMixInfo: PostMixInfo = {
      djName: getValueFromResponse(responses, Const.KEY_DJ_NAME),
      mail: getValueFromResponse(responses, Const.KEY_MAIL_ADDRESS),
      twitter: getValueFromResponse(responses, Const.KEY_TWITTER_ACCOUNT),
      floor: getFloor(getValueFromResponse(responses, Const.KEY_FLOOR)),
      url: getValueFromResponse(responses, Const.KEY_MIX_URL),
      comment: getValueFromResponse(responses, Const.KEY_COMMENT),
    };

    console.log(JSON.stringify(postMixInfo, null, 2));

    const comment = postMixInfo.comment
      ? `\n意気込み: ${postMixInfo.comment}`
      : "";

    const message = `${postMixInfo.djName}さんが ${getFloorLabel(
      postMixInfo.floor
    )} のMixを投稿してくれたわ！
Mix URL : ${postMixInfo.url}${comment}`;
    console.log(message);

    sendToDiscord({ body: message });
  } catch (e) {
    console.error(e);
    sendToDiscord({ body: e, isErrorNotification: true });
  }
};

const getFloor: (str: string) => FloorType | "unknown" = (str: string) => {
  if (str === Const.VALUE_MAIN) return "main";
  if (str === Const.VALUE_SECOND) return "second";
  if (str === Const.VALUE_LOUNGE) return "lounge";
  return "unknown";
};

const getFloorLabel: (floor: FloorType) => string = (floor: FloorType) => {
  if (floor === "main") return "メイン";
  if (floor === "second") return "セカンド";
  if (floor == "lounge") return "ラウンジ";
  return "unknown";
};

/**
 * 配列の可能性がある文字列を文字列にする。配列の時はjoinする
 * @param str 文字列か1,2次元の配列
 * @returns 文字列 （配列だった場合はカンマ区切り）
 */
const joinStringArray = (str: string | string[] | string[][]) => {
  return typeof str === "string" ? str : str.join(",");
};

/**
 * ExtractResponse から特定のキーのバリューを取得する
 * @param response
 * @param key
 * @returns
 */
const getValueFromResponse = (response: ExtractResponse[], key: string) => {
  return response.find((el) => el.key === key).value;
};
```

```form.d.ts
export type FloorType = "main" | "second" | "lounge" | "unknown";

export type PostMixInfo = {
  djName: string;
  mail: string;
  twitter?: string;
  floor: FloorType;
  url: string;
  comment?: string;
};

/**
 * Formから抽出するときの形式
 */
export type ExtractResponse = {
  key: string;
  value: string;
};
```

</details>


## HPのデプロイ通知

ホームページはmainブランチにマージされたときにNetlifyの自動的デプロイを実施するようにしていますが、その完了通知をDiscordに送るように設定しました。こちらも、Netlify の Webhook 通知をWeb APIとして作成したGoogleAppsScriptに向けてなげ、それをさばいてDiscordに通知を投げてます。
NetlifyからのWebhook通知は本当に通知だけ来て中に情報が何もなく、どのような通知か判別できないです。最終的にはちょっと無理くりですが開発環境ごとに GoogleAppsScript のエンドポイントを作り、それぞれ投げる地道なことをしています。この辺もうまいことやりようがあるのかな。。。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641384/ebd867ff-c9bc-1e10-25a6-f2ec0b181764.png)

## ハッシュタグのパブサ

TwitterのAPIなんて扱いたくないので、雑にIFTTTで設定しただけです。設定方法はググれば無限に出てくるのでそちらに譲ります。


# おわりに

イベント運営を軸に色々と内部の整備をしていましたが、この過程で勉強した内容が本職でちょっと活かせたりということもありとても有意義でした。
次回以降の開催に向けても色々反省点ありますが、それをまた解決していきつつまた楽しいパーティが作れたらなと思います。

いろいろやってみたいことはあるけど実装できるメンバーがいない、等ございましたら何かしらの手助けができるかもしれませんのでお気軽にお声がけください。
助けになれるかは保証できませんが、少しでもクラブカルチャーの発展に寄与できれば幸いです。