import { randomUUID } from "node:crypto"; import { Client } from "@microsoft/microsoft-graph-client"; import { PlanContext, Priority, Progress, RecentTaskContext } from "../llm/types"; /** * Thin wrapper around the Graph Planner endpoints we actually use. * * IMPORTANT: Planner write endpoints require the resource's @odata.etag in * an If-Match header. We always re-fetch immediately before PATCH/DELETE * to grab a fresh ETag. */ export class PlannerClient { constructor(private readonly graph: Client) {} /** All plans the signed-in user can see, with buckets/members/labels pre-fetched. * Plans whose title contains "deprecated" (case-insensitive) are filtered out * so old/archived plans don't pollute the LLM context. */ async listPlansWithBuckets(): Promise { const [plansResp, me] = await Promise.all([ this.graph.api("/me/planner/plans").get(), this.graph.api("/me").select(["id"]).get(), ]); const meId: string = me.id; const plans: Array<{ id: string; title: string; owner?: string }> = (plansResp.value ?? []) .filter((p: { title?: string }) => !/deprecated/i.test(p.title ?? "")); const enriched = await Promise.all( plans.map(async (p) => { // Buckets + plan-details + group-members in parallel. const [bucketsResp, planDetails, groupMembers] = await Promise.all([ this.graph.api(`/planner/plans/${p.id}/buckets`).get(), this.graph .api(`/planner/plans/${p.id}/details`) .get() .catch(() => ({ categoryDescriptions: {} })), this.fetchPlanMembers(p.id, p.owner), ]); const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? []; const cd = (planDetails.categoryDescriptions ?? {}) as Record; const labels = Object.entries(cd) .map(([key, name]) => ({ key, name })) .filter((x): x is { key: string; name: string } => typeof x.name === "string" && x.name.length > 0) .map((x) => ({ slot: slotFromKey(x.key), name: x.name })) .filter((l) => l.slot > 0); return { planId: p.id, planTitle: p.title, buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })), members: groupMembers.map((m) => ({ userId: m.id, displayName: m.displayName, ...(m.id === meId ? { isMe: true as const } : {}), })), labels, } satisfies PlanContext; }), ); return enriched; } /** Resolve owning M365 group's members. Falls back gracefully if owner missing. */ private async fetchPlanMembers( planId: string, ownerGroupId?: string, ): Promise> { let groupId = ownerGroupId; if (!groupId) { const plan = await this.graph .api(`/planner/plans/${planId}`) .select(["id", "owner"]) .get() .catch(() => undefined); groupId = plan?.owner; } if (!groupId) return []; const resp = await this.graph .api(`/groups/${groupId}/members`) .select(["id", "displayName"]) .get() .catch(() => ({ value: [] })); type DirObj = { id?: string; displayName?: string; "@odata.type"?: string }; const users: Array<{ id: string; displayName: string }> = (resp.value ?? []) .filter((m: DirObj) => !m["@odata.type"] || m["@odata.type"] === "#microsoft.graph.user") .filter((m: DirObj): m is { id: string; displayName: string } => typeof m.id === "string" && typeof m.displayName === "string" && m.displayName.length > 0, ); return users; } /** Recent (non-completed) tasks across the user's plans, capped. */ async listRecentTasks(limit = 20): Promise { const resp = await this.graph.api("/me/planner/tasks").get(); type RawTask = { id: string; title: string; planId: string; bucketId: string; percentComplete: number; priority?: number; assignments?: Record; createdDateTime: string; }; const tasks: RawTask[] = resp.value ?? []; return tasks .filter((t) => t.percentComplete < 100) .sort((a, b) => (a.createdDateTime < b.createdDateTime ? 1 : -1)) .slice(0, limit) .map((t) => ({ taskId: t.id, title: t.title, planId: t.planId, bucketId: t.bucketId, percentComplete: t.percentComplete, priority: priorityFromInt(t.priority), assigneeIds: t.assignments ? Object.keys(t.assignments) : undefined, })); } async createTask(args: { planId: string; bucketId: string; title: string; progress?: Progress; priority?: Priority; startDate?: string; dueDate?: string; description?: string; assigneeUserIds?: string[]; /** Plan-label slots (1..25) to apply. */ categorySlots?: number[]; checklistItems?: string[]; }): Promise<{ taskId: string; checklistOverflow: number }> { const body: Record = { planId: args.planId, bucketId: args.bucketId, title: args.title, percentComplete: progressToPercent(args.progress) ?? 0, }; if (args.startDate) body.startDateTime = toIsoStartOfDay(args.startDate); if (args.dueDate) body.dueDateTime = toIsoEod(args.dueDate); const priorityInt = priorityToInt(args.priority); if (priorityInt !== undefined) body.priority = priorityInt; if (args.assigneeUserIds && args.assigneeUserIds.length) { body.assignments = buildAssignmentsForCreate(args.assigneeUserIds); } if (args.categorySlots && args.categorySlots.length) { body.appliedCategories = buildAppliedCategories(args.categorySlots); } const created = await this.graph.api("/planner/tasks").post(body); const taskId: string = created.id; // Combine description + checklist into a single details PATCH. const checklistItems = (args.checklistItems ?? []).slice(0, 20); const checklistOverflow = Math.max(0, (args.checklistItems?.length ?? 0) - 20); if (args.description || checklistItems.length > 0) { const detailsPatch: Record = {}; if (args.description) detailsPatch.description = args.description; if (checklistItems.length > 0) detailsPatch.checklist = buildChecklist(checklistItems); const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get(); const etag: string = details["@odata.etag"]; await this.graph .api(`/planner/tasks/${taskId}/details`) .header("If-Match", etag) .header("Prefer", "return=representation") .patch(detailsPatch); } return { taskId, checklistOverflow }; } async updateTask(args: { taskId: string; newBucketId?: string; progress?: Progress; percentComplete?: number; priority?: Priority; startDate?: string; dueDate?: string; newTitle?: string; /** When provided, this is the full new set of assignees — old ones are removed. */ assigneeUserIds?: string[]; currentAssigneeIds?: string[]; /** Plan-label slots (1..25) to apply (full set after this update). */ categorySlots?: number[]; currentCategorySlots?: number[]; }): Promise { const current = await this.graph.api(`/planner/tasks/${args.taskId}`).get(); const etag: string = current["@odata.etag"]; const patch: Record = {}; const pct = args.percentComplete ?? progressToPercent(args.progress); if (pct !== undefined) patch.percentComplete = pct; if (args.newTitle) patch.title = args.newTitle; if (args.startDate) patch.startDateTime = toIsoStartOfDay(args.startDate); if (args.dueDate) patch.dueDateTime = toIsoEod(args.dueDate); if (args.newBucketId) patch.bucketId = args.newBucketId; const priorityInt = priorityToInt(args.priority); if (priorityInt !== undefined) patch.priority = priorityInt; if (args.assigneeUserIds) { patch.assignments = buildAssignmentsForReplace( args.currentAssigneeIds ?? [], args.assigneeUserIds, ); } if (args.categorySlots) { patch.appliedCategories = buildAppliedCategoriesDiff( args.currentCategorySlots ?? [], args.categorySlots, ); } if (Object.keys(patch).length === 0) return; await this.graph .api(`/planner/tasks/${args.taskId}`) .header("If-Match", etag) .patch(patch); } /** Append items to a task's existing checklist on its /details resource. * Returns how many items overflowed (Planner caps at 20). */ async appendChecklist(taskId: string, items: string[]): Promise<{ overflow: number }> { if (items.length === 0) return { overflow: 0 }; const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get(); const etag: string = details["@odata.etag"]; const existing = (details.checklist ?? {}) as Record; const remainingCapacity = Math.max(0, 20 - Object.keys(existing).length); const toAdd = items.slice(0, remainingCapacity); const overflow = items.length - toAdd.length; if (toAdd.length === 0) return { overflow }; const additions = buildChecklist(toAdd); await this.graph .api(`/planner/tasks/${taskId}/details`) .header("If-Match", etag) .patch({ checklist: additions }); return { overflow }; } async appendTaskNote(taskId: string, note: string): Promise { const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get(); const etag: string = details["@odata.etag"]; const previous: string = details.description ?? ""; const stamp = new Date().toISOString().slice(0, 16).replace("T", " "); const combined = previous ? `${previous}\n[${stamp}] ${note}` : `[${stamp}] ${note}`; await this.graph .api(`/planner/tasks/${taskId}/details`) .header("If-Match", etag) .patch({ description: combined }); } /** Ensure a plan-level label exists with the given name. Returns its slot * (1..25). Picks the lowest empty slot for new labels. Throws if 25 slots * are all taken. */ async ensurePlanLabel(planId: string, name: string): Promise { const details = await this.graph.api(`/planner/plans/${planId}/details`).get(); const etag: string = details["@odata.etag"]; const cd = (details.categoryDescriptions ?? {}) as Record; for (let slot = 1; slot <= 25; slot++) { const key = `category${slot}`; if (cd[key] && cd[key].trim().toLowerCase() === name.trim().toLowerCase()) { return slot; } } let emptySlot: number | null = null; for (let slot = 1; slot <= 25; slot++) { const key = `category${slot}`; if (!cd[key]) { emptySlot = slot; break; } } if (emptySlot === null) { throw new Error(`Plan 의 라벨 슬롯이 25개 모두 차있어 '${name}' 을 등록할 수 없어요.`); } await this.graph .api(`/planner/plans/${planId}/details`) .header("If-Match", etag) .patch({ categoryDescriptions: { [`category${emptySlot}`]: name } }); return emptySlot; } } function progressToPercent(p?: Progress): number | undefined { switch (p) { case "notStarted": return 0; case "inProgress": return 50; case "completed": return 100; default: return undefined; } } function priorityToInt(p?: Priority): number | undefined { switch (p) { case "urgent": return 1; case "important": return 3; case "medium": return 5; case "low": return 9; default: return undefined; } } function priorityFromInt(n?: number): Priority | undefined { if (n === undefined) return undefined; if (n <= 1) return "urgent"; if (n <= 3) return "important"; if (n <= 5) return "medium"; return "low"; } function buildAssignmentsForCreate(userIds: string[]): Record { const out: Record = {}; for (const id of userIds) { out[id] = { "@odata.type": "#microsoft.graph.plannerAssignment", orderHint: " !", }; } return out; } /** Build an assignments PATCH that removes old assignees (set to null) and * adds the new ones, preserving any user who is in both sets. */ function buildAssignmentsForReplace( currentIds: string[], newIds: string[], ): Record { const out: Record = {}; const current = new Set(currentIds); const target = new Set(newIds); for (const id of current) { if (!target.has(id)) out[id] = null; } for (const id of target) { if (!current.has(id)) { out[id] = { "@odata.type": "#microsoft.graph.plannerAssignment", orderHint: " !", }; } } return out; } function buildAppliedCategories(slots: number[]): Record { const out: Record = {}; for (const s of slots) { if (s >= 1 && s <= 25) out[`category${s}`] = true; } return out; } /** Diff appliedCategories so we explicitly set removed slots to false. */ function buildAppliedCategoriesDiff( currentSlots: number[], newSlots: number[], ): Record { const out: Record = {}; const target = new Set(newSlots); for (const s of currentSlots) { if (!target.has(s)) out[`category${s}`] = false; } for (const s of newSlots) { if (s >= 1 && s <= 25) out[`category${s}`] = true; } return out; } function buildChecklist(items: string[]): Record { const out: Record = {}; for (const title of items) { out[randomUUID()] = { "@odata.type": "#microsoft.graph.plannerChecklistItem", title, isChecked: false, }; } return out; } function slotFromKey(key: string): number { const m = /^category(\d+)$/.exec(key); return m ? Number(m[1]) : 0; } function toIsoEod(yyyyMmDd: string): string { return `${yyyyMmDd}T23:59:59Z`; } function toIsoStartOfDay(yyyyMmDd: string): string { return `${yyyyMmDd}T00:00:00Z`; }