メインコンテンツへスキップ
見出し画像

Slackから freee へシームレス打刻。attendance機能の裏側

    こんにちは!Accel-Hack(アクセルハック) 開発チームです。

    社内AIアシスタント「Airi」の開発プロセスをお届けする開発記ブログ。
    第13回目の今回から、いよいよ具体的な業務機能の実装編に入ります。

    トップバッターは、毎日みんなが使う 勤怠打刻(attendance機能) です。

    前回までで、Vol.11 の freee 認証、Vol.12 の Slack App Home と、道具立ては揃いました。

    今回はそれらを組み合わせて、「Slack のボタン一つで freee に打刻する」体験を作ります。

    一見シンプルなこの機能――しかし本当に骨が折れたのは、ボタンが連打されたときの設計でした。


    1. 打刻は一日に何度も。だから「摩擦ゼロ」を狙う

    出社、退勤、休憩、再開――勤怠の打刻は、一人の社員が一日に何度も行う操作です。

    この高頻度の操作に少しでも手間があれば、「あとでまとめて」となり、やがて打刻漏れを生みます。

    だから attendance機能で最優先にしたのは、とにかく摩擦をなくすことでした。
    Vol.12 で作った App Home に「出社」「退勤」「休憩」といったボタンをずらりと並べ、ワンタップで打刻が完了する。

    コマンドを打つ必要も、freee の画面を開く必要もありません。

    いつもの Slack の中で、指一本。
    この気持ちよさが、正確な勤怠記録の土台になります。


    2. 6つのボタンを、1つのハンドラで受ける

    打刻のボタンは1種類ではありません。

    出社(オフィス/リモート/その他)、退勤、休憩開始、休憩終了――全部で6つあります。

    これを6つのハンドラで書くと、同じような処理が6箇所に散らばってしまいます。

    そこで Vol.10 の総当たりディスパッチと同じ発想で、app.action に正規表現を渡し、6つのアクションを1箇所で受け止めるようにしました。

    app.action(
      /^freee_(clock_in_office|clock_in_remote|clock_in_other|clock_out|break_begin|break_end)$/,
      async () => {}, // ack
      async (req) => {
        // action_id から打刻種別(type)と通知用の種別を組み立てて…
        await handleAttendanceAction(type, notificationType)({ context, payload });
      },
    );

    受け取った先の handleAttendanceAction がやることは、驚くほどシンプルな3手です。

    freee に打刻を記録し(recordAttendance)、App Home を描き直し(publishHomeView)、勤怠スレッドに投稿する(notifyAttendanceToSlack)。

    このとき使うアクセストークンは、Vol.11 で作った仕組みが「いつでも使える状態」で用意してくれます。

    過去回の部品が、ここで気持ちよく噛み合うわけです。


    3. 二重打刻を防ぐ――打刻の前に、freee へ「今なにが押せるか」を聞く

    さて、ここからが本題です。

    ボタンを押しても、freee への通信には数秒かかります。
    その間ボタンは無反応に見えるので、ユーザーは「押せたのかな?」と不安になり、必ずもう一度押します。

    勤怠は「お金と時間」に直結するため、これで二重に打刻が入るのは絶対に避けたいところでした。

    Airi が採った答えは、押されたあとに何とかするのではなく、そもそも記録させないことでした。
    打刻を投げる前に、freee へ「このユーザーがいま実行できる打刻種別は何か」を問い合わせるのです。

    const typesRes = await freee.hr.getAvailableTimeClockTypes(
      accessToken,
      employeeId,
      companyId,
    );
    baseDate = typesRes.base_date;
    availableTypes = typesRes.available_types;
    
    // すでに打刻済みのステータスである場合は、重複リクエストとみなして処理をスキップする
    if (availableTypes.length > 0 && !availableTypes.includes(type)) {
      return false;
    }

    すでに出社済みの人が「出社」をもう一度押しても、freee 側の available_types に clock_in は入っていません。

    だからここで静かにスキップし、false を返します。

    呼び出し元はこの戻り値を見て、Slack への通知も出しません。
    「◯◯さんが出社しました」が二度流れる、あの気まずさが起きないわけです。

    そしてもう一つ、この問い合わせには嬉しいおまけがありました。レスポンスに含まれる base_date です。

    深夜をまたぐ勤務では「押した瞬間の日付」と「その打刻が属する勤務日」がズレます。

    freee が教えてくれる基準日をそのまま添えて打刻することで、日付をまたいでも正しい勤務日に記録できるようになりました。

    画像

    4. エラーは「freee の言い分」を翻訳して届ける

    もう一つ気を配ったのが、失敗したときに何が起きたのか本人に分かることです。

    外部APIのエラーは、そのまま出しても「400 Bad Request」しか伝わりません。
    しかし freee はレスポンス本文に、かなり具体的な理由を返してくれます。

    そこで独自の FreeeAPIError に、本文を JSON として読み解き、errors[].messages を掘り出して人間が読める一行に組み立てる役目を持たせました。

    const messages = errors.flatMap((err) => err.messages || []);
    if (messages.length > 0) {
      detailedMessage = `${message} (Detail: ${messages.join(", ")})`;
    }
    detailedMessage = `${detailedMessage} (Status: ${status} ${statusText})`;

    こうして組み立てたメッセージを、打刻中にエラーが発生しました。
    詳細: ... という形で本人に DM します。

    加えて、この通知自体が失敗しても打刻処理を巻き込まないよう、try/catch を二段に重ねています。
    「エラーを伝えようとして、さらにエラーになる」のは避けたいですからね。


    5. おわりに

    今回は、勤怠打刻を 「ボタン一つの摩擦ゼロ体験」 として実装しつつ、「二重打刻は、防ぐのではなく最初から成立させない」 という設計に落ち着くまでをお届けしました。

    連打を責めるのではなく、押されても平気な作りにする。実際の業務で毎日使われるツールならではの学びでした。

    もっとも、この時点ではまだ「押してから数秒の沈黙」そのものは残ったままです。

    進捗をどう見せるか、タイムアウトをどう扱うかは、もう少し先の回で正面から向き合うことになります。

    次回は、同じく毎日の業務に欠かせない申請業務――「Vol.14: バックオフィス業務の効率化『paid-holiday機能』」 として、Slack のモーダルを活用した、直感的な有給休暇の申請フローをお届けします。お楽しみに!


    この記事は以下の体制で作成されました。

    • ドラフト作成・執筆: Airi (社内AIアシスタント)

    • アイデア出し・レビュー: CTO

    • デザイン: 事務スタッフ

    Accel-Hackでは、テクノロジーを駆使して世の中の課題を解決していく仲間を募集しています。
    私たちの取り組みに少しでも興味を持っていただけたら、ぜひ採用ページや会社サイトも覗いてみてくださいね。


    あなたへのおすすめ