Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@
"panel": "new"
}
},
{
"label": "Convex環境変数セットアップ",
"type": "shell",
"command": "pnpm convex:env:setup",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated"
}
},
{
"label": "difit",
"type": "shell",
Expand Down
10 changes: 10 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
*/

import type * as constants from "../constants.js";
import type * as email_actions from "../email/actions.js";
import type * as helpers from "../helpers.js";
import type * as invite_mutations from "../invite/mutations.js";
import type * as invite_queries from "../invite/queries.js";
import type * as position_mutations from "../position/mutations.js";
import type * as position_queries from "../position/queries.js";
import type * as recruitment_mutations from "../recruitment/mutations.js";
import type * as recruitment_queries from "../recruitment/queries.js";
import type * as requiredStaffing_mutations from "../requiredStaffing/mutations.js";
import type * as requiredStaffing_queries from "../requiredStaffing/queries.js";
import type * as shiftRequest_mutations from "../shiftRequest/mutations.js";
import type * as shiftRequest_queries from "../shiftRequest/queries.js";
import type * as shop_mutations from "../shop/mutations.js";
import type * as shop_queries from "../shop/queries.js";
import type * as staffSkill_mutations from "../staffSkill/mutations.js";
Expand All @@ -32,13 +37,18 @@ import type {

declare const fullApi: ApiFromModules<{
constants: typeof constants;
"email/actions": typeof email_actions;
helpers: typeof helpers;
"invite/mutations": typeof invite_mutations;
"invite/queries": typeof invite_queries;
"position/mutations": typeof position_mutations;
"position/queries": typeof position_queries;
"recruitment/mutations": typeof recruitment_mutations;
"recruitment/queries": typeof recruitment_queries;
"requiredStaffing/mutations": typeof requiredStaffing_mutations;
"requiredStaffing/queries": typeof requiredStaffing_queries;
"shiftRequest/mutations": typeof shiftRequest_mutations;
"shiftRequest/queries": typeof shiftRequest_queries;
"shop/mutations": typeof shop_mutations;
"shop/queries": typeof shop_queries;
"staffSkill/mutations": typeof staffSkill_mutations;
Expand Down
4 changes: 4 additions & 0 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ export type StaffRoleType = (typeof STAFF_ROLES)[number];
// 招待関連
export const INVITE_EXPIRY_DAYS = 14;
export const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000;

// 募集ステータス定義
export const RECRUITMENT_STATUS = ["open", "closed", "confirmed"] as const;
export type RecruitmentStatusType = (typeof RECRUITMENT_STATUS)[number];
89 changes: 89 additions & 0 deletions convex/email/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* メール送信ドメイン - アクション(外部API呼び出し)
*
* 責務:
* - Resend APIを使用したメール送信
* - シフト募集通知メールの送信
*/
import { v } from "convex/values";
import { Resend } from "resend";
import { internalAction } from "../_generated/server";

const FROM_EMAIL = "onboarding@resend.dev";

// シフト募集通知メール送信
export const sendRecruitmentNotification = internalAction({
args: {
shopName: v.string(),
startDate: v.string(),
endDate: v.string(),
deadline: v.string(),
recipients: v.array(
v.object({
email: v.string(),
magicLinkToken: v.string(),
}),
),
},
handler: async (_ctx, args) => {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.error("RESEND_API_KEY が設定されていません");
return;
}

const resend = new Resend(apiKey);
const appUrl = process.env.APP_URL ?? "http://localhost:3000";

const subject = `【${args.shopName}】シフト募集のお知らせ(${args.startDate}〜${args.endDate})`;

for (const recipient of args.recipients) {
try {
const magicLinkUrl = `${appUrl}/shift-submit?token=${recipient.magicLinkToken}`;

await resend.emails.send({
from: FROM_EMAIL,
to: recipient.email,
subject,
html: buildEmailHtml({
shopName: args.shopName,
startDate: args.startDate,
endDate: args.endDate,
deadline: args.deadline,
magicLinkUrl,
}),
});
} catch (e) {
console.error(`メール送信失敗: ${recipient.email}`, e);
}
}
},
});

// メールHTML組み立て
const buildEmailHtml = (params: {
shopName: string;
startDate: string;
endDate: string;
deadline: string;
magicLinkUrl: string;
}) => {
return `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2>${params.shopName} のシフト募集が開始されました</h2>
<table style="margin: 20px 0; border-collapse: collapse;">
<tr>
<td style="padding: 8px 16px 8px 0; font-weight: bold;">募集期間</td>
<td style="padding: 8px 0;">${params.startDate} 〜 ${params.endDate}</td>
</tr>
<tr>
<td style="padding: 8px 16px 8px 0; font-weight: bold;">申請締切</td>
<td style="padding: 8px 0;">${params.deadline}</td>
</tr>
</table>
<p>以下のリンクからシフトを申請してください:</p>
<a href="${params.magicLinkUrl}" style="display: inline-block; background: #0d9488; color: #fff; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin: 12px 0;">シフトを申請する</a>
<p style="color: #666; font-size: 14px; margin-top: 24px;">※ このリンクはあなた専用です。他の方と共有しないでください。</p>
</div>
`.trim();
};
109 changes: 109 additions & 0 deletions convex/recruitment/mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* 募集ドメイン - ミューテーション(書き込み操作)
*
* 責務:
* - シフト募集の作成
*/
import { ConvexError, v } from "convex/values";
import { internal } from "../_generated/api";
import { mutation } from "../_generated/server";
import { RECRUITMENT_STATUS } from "../constants";
import { generateToken, requireShop, requireShopOwnerOrManager } from "../helpers";

// 日付形式バリデーション(YYYY-MM-DD形式)
const isValidDateFormat = (date: string) => {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(date)) return false;
const parsed = new Date(date);
return !Number.isNaN(parsed.getTime());
};

// シフト募集作成
export const create = mutation({
args: {
shopId: v.id("shops"),
authId: v.string(),
startDate: v.string(),
endDate: v.string(),
deadline: v.string(),
},
handler: async (ctx, args) => {
// 店舗存在チェック
await requireShop(ctx, args.shopId);

// 権限チェック(オーナーまたはマネージャーのみ)
await requireShopOwnerOrManager(ctx, args.shopId, args.authId);

// 日付形式バリデーション
if (!isValidDateFormat(args.startDate)) {
throw new ConvexError({ message: "開始日の形式が不正です", code: "INVALID_START_DATE" });
}
if (!isValidDateFormat(args.endDate)) {
throw new ConvexError({ message: "終了日の形式が不正です", code: "INVALID_END_DATE" });
}
if (!isValidDateFormat(args.deadline)) {
throw new ConvexError({ message: "締切日の形式が不正です", code: "INVALID_DEADLINE" });
}

// ビジネスルールバリデーション(フロントエンドのzodスキーマと一致)
if (args.startDate > args.endDate) {
throw new ConvexError({ message: "終了日は開始日以降を指定してください", code: "END_BEFORE_START" });
}
if (args.deadline >= args.startDate) {
throw new ConvexError({
message: "締切日は開始日より前を指定してください",
code: "DEADLINE_NOT_BEFORE_START",
});
}

// アクティブスタッフ数を取得(非削除かつ非退職)
const activeStaffs = await ctx.db
.query("staffs")
.withIndex("by_shop", (q) => q.eq("shopId", args.shopId))
.filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("status"), "resigned")))
.collect();

const totalStaffCount = activeStaffs.length;

// 各スタッフにマジックリンクトークンを生成・更新
const deadlineEnd = new Date(`${args.deadline}T23:59:59`).getTime();
const recipients: { email: string; magicLinkToken: string }[] = [];

for (const staff of activeStaffs) {
const token = generateToken();
await ctx.db.patch(staff._id, {
magicLinkToken: token,
magicLinkExpiresAt: deadlineEnd,
});
recipients.push({ email: staff.email, magicLinkToken: token });
}

// 募集作成
const recruitmentId = await ctx.db.insert("recruitments", {
shopId: args.shopId,
startDate: args.startDate,
endDate: args.endDate,
deadline: args.deadline,
status: RECRUITMENT_STATUS[0], // "open"
appliedCount: 0,
totalStaffCount,
createdBy: args.authId,
createdAt: Date.now(),
isDeleted: false,
});

// 店舗情報を取得してメール送信をスケジュール
const shop = await requireShop(ctx, args.shopId);
if (recipients.length > 0) {
await ctx.scheduler.runAfter(0, internal.email.actions.sendRecruitmentNotification, {
shopName: shop.shopName,
startDate: args.startDate,
endDate: args.endDate,
deadline: args.deadline,
recipients,
});
}

return { success: true, data: { recruitmentId, totalStaffCount } };
},
});
33 changes: 33 additions & 0 deletions convex/recruitment/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* 募集ドメイン - クエリ(読み取り操作)
*
* 責務:
* - 店舗のシフト募集一覧取得
*/
import { v } from "convex/values";
import { query } from "../_generated/server";
import type { RecruitmentStatusType } from "../constants";

// 店舗の募集一覧取得
export const listByShop = query({
args: { shopId: v.id("shops") },
handler: async (ctx, args) => {
const recruitments = await ctx.db
.query("recruitments")
.withIndex("by_shop_and_startDate", (q) => q.eq("shopId", args.shopId))
.order("desc")
.filter((q) => q.neq(q.field("isDeleted"), true))
.collect();

return recruitments.map((r) => ({
_id: r._id,
startDate: r.startDate,
endDate: r.endDate,
deadline: r.deadline,
status: r.status as RecruitmentStatusType,
appliedCount: r.appliedCount,
totalStaffCount: r.totalStaffCount,
confirmedAt: r.confirmedAt,
}));
},
});
39 changes: 39 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,52 @@ const requiredStaffing = defineTable({
updatedAt: v.number(),
}).index("by_shop", ["shopId"]);

// シフト提出テーブル(スタッフがマジックリンクから提出)
const shiftRequests = defineTable({
recruitmentId: v.id("recruitments"),
staffId: v.id("staffs"),
entries: v.array(
v.object({
date: v.string(), // "YYYY-MM-DD"
isAvailable: v.boolean(),
startTime: v.optional(v.string()), // "09:00"(isAvailable=true時)
endTime: v.optional(v.string()), // "17:00"(isAvailable=true時)
}),
),
submittedAt: v.number(),
updatedAt: v.optional(v.number()),
})
.index("by_recruitment", ["recruitmentId"])
.index("by_staff", ["staffId"])
.index("by_recruitment_and_staff", ["recruitmentId", "staffId"]);

// シフト募集テーブル
const recruitments = defineTable({
shopId: v.id("shops"),
startDate: v.string(), // YYYY-MM-DD
endDate: v.string(), // YYYY-MM-DD
deadline: v.string(), // YYYY-MM-DD
status: v.string(), // "open" | "closed" | "confirmed"
appliedCount: v.number(), // 申請済みスタッフ数(初期値: 0)
totalStaffCount: v.number(), // 作成時のアクティブスタッフ数
confirmedAt: v.optional(v.number()),
createdBy: v.string(), // authId
createdAt: v.number(),
isDeleted: v.boolean(),
})
.index("by_shop", ["shopId"])
.index("by_shop_and_status", ["shopId", "status"])
.index("by_shop_and_startDate", ["shopId", "startDate"]);

const schema = defineSchema({
users,
shops,
staffs,
shopPositions,
staffSkills,
requiredStaffing,
shiftRequests,
recruitments,
});

// テーブル名を型安全にエクスポート(testing.tsで使用)
Expand Down
Loading