feat: bidirectional Outlook/MS To-Do sync, busyStatus + star, list-delete dialog
- Outlook recurring delete: also delete the specific instance after the master
to clean up orphaned first occurrences.
- CachedCalendarEvent gains busyStatus column (migration applied) so showAs
changes from Outlook flow back into the weekly view.
- Outlook create/update now round-trip the full event (busyStatus, visibility,
attendees, reminders, recurrence link) via a shared response mapper.
- MS To-Do importance ('high' star) ⇄ local importance flag in pull-sync,
push-sync, initial import, and local-task POST/PATCH; dueDateTime ⇄
scheduledDate added to pull-sync.
- Local task PATCH now fires a best-effort push to Outlook/Google/Synology so
any field change keeps both sides aligned.
- AnyDay list delete dialog now offers Cancel / hide locally / delete on both
sides; new deleteMsTodoList + deleteGoogleTaskList helpers.
v1.103.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
55cc066707
commit
42905641cd
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.102.0",
|
||||
"version": "1.103.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.102.0",
|
||||
"version": "1.103.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.102.0",
|
||||
"version": "1.103.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- CachedCalendarEvent: store busy/free/tentative/oof status for incoming Outlook/Google sync
|
||||
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "busyStatus" TEXT;
|
||||
@ -279,6 +279,7 @@ model CachedCalendarEvent {
|
||||
recurringEventId String?
|
||||
isRecurring Boolean @default(false)
|
||||
reminders Json?
|
||||
busyStatus String?
|
||||
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@ -121,6 +121,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const id = searchParams.get('id');
|
||||
// tasksOnly=true: soft-disconnect (keep list record with tab, just remove tasks + external link)
|
||||
const tasksOnly = searchParams.get('tasksOnly') === 'true';
|
||||
// deleteExternal=true: also remove the list from the connected provider (Outlook/Google)
|
||||
const deleteExternal = searchParams.get('deleteExternal') === 'true';
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -141,6 +143,33 @@ export async function DELETE(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If asked to remove the list from the external provider too, do so first
|
||||
// (so a Graph/API failure doesn't orphan the local state).
|
||||
let externalDeleteFailed = false;
|
||||
if (deleteExternal && list.externalId && list.externalProvider) {
|
||||
try {
|
||||
if (list.externalProvider === 'outlook') {
|
||||
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||
const { deleteMsTodoList } = await import('@/lib/microsoft-todo');
|
||||
const token = await getOutlookAccessToken(userId);
|
||||
if (token) await deleteMsTodoList(token, list.externalId);
|
||||
} else if (list.externalProvider === 'google') {
|
||||
const { createGoogleClient, deleteGoogleTaskList } = await import('@/lib/google-tasks');
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId, provider: { in: ['google-calendar', 'google'] } }
|
||||
});
|
||||
if (account?.access_token) {
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
await deleteGoogleTaskList(client, list.externalId);
|
||||
}
|
||||
}
|
||||
// Note: Synology task list deletion not yet implemented at the provider level.
|
||||
} catch (extErr) {
|
||||
console.error('Failed to delete external list, proceeding with local cleanup:', extErr);
|
||||
externalDeleteFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-delete tasks in this list (they can be recovered from trash)
|
||||
await prisma.task.updateMany({
|
||||
where: { somedayListId: id },
|
||||
@ -162,7 +191,7 @@ export async function DELETE(request: NextRequest) {
|
||||
});
|
||||
|
||||
notifyUser(userId, "list-changed", { action: "deleted" });
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, externalDeleteFailed });
|
||||
} catch (error) {
|
||||
console.error('Error deleting someday list:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -21,6 +21,7 @@ interface ImportedTask {
|
||||
status: string;
|
||||
sourceListTitle: string;
|
||||
parentExternalId?: string;
|
||||
important?: boolean;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@ -126,6 +127,7 @@ export async function POST(req: NextRequest) {
|
||||
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
|
||||
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
|
||||
sourceListTitle: sourceList.title,
|
||||
important: t.importance === 'high',
|
||||
});
|
||||
|
||||
// Fetch checklist items as sub-tasks
|
||||
@ -293,6 +295,7 @@ export async function POST(req: NextRequest) {
|
||||
somedayListId: somedayList.id,
|
||||
lastSyncedAt: new Date(),
|
||||
deletedAt: null, // clear soft-delete from a previous disconnect
|
||||
...(task.important !== undefined ? { importance: task.important ? true : existingTask.importance } : {}),
|
||||
}
|
||||
});
|
||||
externalToLocalId.set(task.externalId, existingTask.id);
|
||||
@ -311,7 +314,8 @@ export async function POST(req: NextRequest) {
|
||||
externalId: task.externalId,
|
||||
externalProvider: provider,
|
||||
externalListId: task.externalListId,
|
||||
lastSyncedAt: new Date()
|
||||
lastSyncedAt: new Date(),
|
||||
...(task.important ? { importance: true } : {}),
|
||||
}
|
||||
});
|
||||
externalToLocalId.set(task.externalId, newTask.id);
|
||||
|
||||
@ -24,6 +24,71 @@ const generateVirtualId = (originalId: string, dateStr: string) => {
|
||||
return `virtual-${originalId}-${dateStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Push a single task field-update to its external provider (Outlook / Google / Synology).
|
||||
* Best-effort: fire-and-forget from the caller, which logs errors.
|
||||
*/
|
||||
async function pushTaskToExternal(
|
||||
task: Task,
|
||||
fields: { title?: string; notes?: string; completed?: boolean; scheduledDate?: string | null; importance?: boolean | null }
|
||||
): Promise<void> {
|
||||
if (!task.externalProvider || !task.externalId || !task.externalListId) return;
|
||||
|
||||
if (task.externalProvider === 'outlook') {
|
||||
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||
const { updateMsTodoTask } = await import('@/lib/microsoft-todo');
|
||||
const token = await getOutlookAccessToken(task.userId);
|
||||
if (!token) return;
|
||||
const updates: any = {};
|
||||
if (fields.title !== undefined) updates.title = fields.title;
|
||||
if (fields.notes !== undefined) updates.body = fields.notes ?? '';
|
||||
if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'notStarted';
|
||||
if (fields.scheduledDate !== undefined) {
|
||||
updates.dueDateTime = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
|
||||
}
|
||||
if (fields.importance !== undefined) updates.importance = fields.importance ? 'high' : 'normal';
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateMsTodoTask(token, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
} else if (task.externalProvider === 'google') {
|
||||
const { createGoogleClient, updateGoogleTask } = await import('@/lib/google-tasks');
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } }
|
||||
});
|
||||
if (!account?.access_token) return;
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
const updates: any = {};
|
||||
if (fields.title !== undefined) updates.title = fields.title;
|
||||
if (fields.notes !== undefined) updates.notes = fields.notes ?? '';
|
||||
if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'needsAction';
|
||||
if (fields.scheduledDate !== undefined) {
|
||||
updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
|
||||
}
|
||||
// Google Tasks API has no native importance/star — silently ignored.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateGoogleTask(client, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
} else if (task.externalProvider === 'synology') {
|
||||
const { updateSynologyTask } = await import('@/lib/synology-tasks');
|
||||
const conn = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: task.userId, provider: 'synology' }
|
||||
});
|
||||
if (!conn?.accessToken || !conn?.refreshToken) return;
|
||||
const [user, pw] = conn.accessToken.split(':');
|
||||
if (!user || !pw) return;
|
||||
const updates: any = {};
|
||||
if (fields.title !== undefined) updates.title = fields.title;
|
||||
if (fields.notes !== undefined) updates.notes = fields.notes ?? '';
|
||||
if (fields.completed !== undefined) updates.completed = fields.completed;
|
||||
if (fields.scheduledDate !== undefined) {
|
||||
updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateSynologyTask(conn.refreshToken, user, pw, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max virtual instances generated per recurring series, keyed by recurrence unit.
|
||||
// Caps pathological cases (e.g. a daily task with 90-day horizon = 90 instances).
|
||||
const MAX_INSTANCES_PER_SERIES: Record<string, number> = {
|
||||
@ -308,7 +373,12 @@ export async function POST(request: NextRequest) {
|
||||
const { getOutlookAccessToken } = await import('@/lib/outlook-token');
|
||||
const accessToken = await getOutlookAccessToken(userId);
|
||||
if (accessToken) {
|
||||
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, { title });
|
||||
const msTask = await createMsTodoTask(accessToken, somedayList.externalId, {
|
||||
title,
|
||||
body: description || undefined,
|
||||
dueDateTime: scheduledDate || undefined,
|
||||
importance: importance ? 'high' : undefined,
|
||||
});
|
||||
externalId = msTask.id;
|
||||
externalProvider = 'outlook';
|
||||
externalListId = somedayList.externalId;
|
||||
@ -509,6 +579,21 @@ export async function PATCH(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Push changes to external provider (fire-and-forget) when this task is linked
|
||||
if (task.externalProvider && task.externalId && task.externalListId) {
|
||||
const pushFields: Record<string, any> = {};
|
||||
if (title !== undefined) pushFields.title = title;
|
||||
if (description !== undefined) pushFields.notes = description;
|
||||
if (completed !== undefined) pushFields.completed = completed;
|
||||
if (scheduledDate !== undefined) pushFields.scheduledDate = scheduledDate;
|
||||
if (importance !== undefined) pushFields.importance = importance;
|
||||
if (Object.keys(pushFields).length > 0) {
|
||||
pushTaskToExternal(task, pushFields).catch(e =>
|
||||
console.error('[TASK-SYNC] external push failed:', e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: We REMOVED the "create next task on completion" logic block here.
|
||||
// Why? Because the projection system handles "next tasks" automatically.
|
||||
// If we kept it, completing a task would create a duplicate materialized task for the next date,
|
||||
|
||||
@ -315,6 +315,23 @@ export async function GET(req: NextRequest) {
|
||||
updateData.description = remoteNotes;
|
||||
}
|
||||
|
||||
// Outlook To-Do star ⇄ local importance flag
|
||||
const remoteImportant = remote.importance === 'high';
|
||||
if (remoteImportant !== (localTask.importance === true)) {
|
||||
updateData.importance = remoteImportant;
|
||||
}
|
||||
|
||||
// Outlook dueDateTime ⇄ local scheduledDate
|
||||
const remoteDue = remote.dueDateTime?.dateTime
|
||||
? new Date(remote.dueDateTime.dateTime)
|
||||
: null;
|
||||
const localDue = localTask.scheduledDate ? new Date(localTask.scheduledDate) : null;
|
||||
const remoteDueMs = remoteDue?.getTime() ?? null;
|
||||
const localDueMs = localDue?.getTime() ?? null;
|
||||
if (remoteDueMs !== localDueMs) {
|
||||
updateData.scheduledDate = remoteDue;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 1) {
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
@ -343,6 +360,10 @@ export async function GET(req: NextRequest) {
|
||||
title: remote.title,
|
||||
description: remote.body?.content || null,
|
||||
completed: isMsTodoTaskCompleted(remote.status),
|
||||
importance: remote.importance === 'high' ? true : null,
|
||||
scheduledDate: remote.dueDateTime?.dateTime
|
||||
? new Date(remote.dueDateTime.dateTime)
|
||||
: null,
|
||||
somedayListId: somedayListInfo.id,
|
||||
externalId: remote.id,
|
||||
externalProvider: 'outlook',
|
||||
@ -484,7 +505,7 @@ export async function PATCH(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { taskId, completed, title, action, scheduledDate, notes } = body;
|
||||
const { taskId, completed, title, action, scheduledDate, notes, importance } = body;
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
|
||||
@ -538,13 +559,16 @@ export async function PATCH(req: NextRequest) {
|
||||
if (action === 'delete') {
|
||||
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
|
||||
} else {
|
||||
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null } = {};
|
||||
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null; importance?: 'low' | 'normal' | 'high' } = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (notes !== undefined) updates.body = notes;
|
||||
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
|
||||
if (scheduledDate !== undefined) {
|
||||
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
|
||||
}
|
||||
if (importance !== undefined) {
|
||||
updates.importance = importance ? 'high' : 'normal';
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
|
||||
}
|
||||
@ -658,6 +682,7 @@ export async function POST(req: NextRequest) {
|
||||
title: task.title,
|
||||
body: task.description || undefined,
|
||||
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
|
||||
importance: task.importance ? 'high' : undefined,
|
||||
});
|
||||
|
||||
const updatedTask = await prisma.task.update({
|
||||
|
||||
@ -234,7 +234,7 @@ export default function CalendarEventModal({
|
||||
end: { dateTime: endDate.toISOString() },
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
reminders: activeReminders.length > 0 ? activeReminders : undefined,
|
||||
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
|
||||
busyStatus: busyStatus,
|
||||
visibility: visibility !== 'default' ? visibility : undefined,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
|
||||
@ -8150,23 +8150,76 @@ export default function WeeklyView() {
|
||||
}}
|
||||
>
|
||||
{listToDelete === list.id ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "8px", padding: "4px" }}>
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
|
||||
{list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListToDelete(null);
|
||||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Cancel</button>
|
||||
<button onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
|
||||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "6px", padding: "4px" }}>
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>
|
||||
{profile.language === 'de' ? 'Diese Liste löschen?' : 'Delete this list?'}
|
||||
</span>
|
||||
{list.externalProvider ? (
|
||||
<>
|
||||
<span style={{ fontSize: "0.75rem", color: "#888" }}>
|
||||
{profile.language === 'de'
|
||||
? `Diese Liste ist mit ${list.externalProvider} verknüpft. Wie soll fortgefahren werden?`
|
||||
: `This list is linked to ${list.externalProvider}. How would you like to proceed?`}
|
||||
</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "4px" }}>
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListToDelete(null);
|
||||
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
|
||||
{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||
</button>
|
||||
<button onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await fetch(`/api/someday-lists?id=${list.id}&tasksOnly=true`, { method: "DELETE" });
|
||||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||||
setListToDelete(null);
|
||||
} catch (err) { console.error(err); }
|
||||
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#3b82f6", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
|
||||
{profile.language === 'de'
|
||||
? `Nur hier ausblenden (in ${list.externalProvider} bleibt sie)`
|
||||
: `Hide here only (keep in ${list.externalProvider})`}
|
||||
</button>
|
||||
<button onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const res = await fetch(`/api/someday-lists?id=${list.id}&deleteExternal=true`, { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.externalDeleteFailed) {
|
||||
alert(profile.language === 'de'
|
||||
? `Hinweis: Die Liste wurde lokal gelöscht, aber nicht aus ${list.externalProvider} (Fehler beim Provider).`
|
||||
: `Note: The list was deleted locally, but not from ${list.externalProvider} (provider error).`);
|
||||
}
|
||||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||||
setListToDelete(null);
|
||||
} catch (err) { console.error(err); }
|
||||
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
|
||||
{profile.language === 'de'
|
||||
? `Hier UND in ${list.externalProvider} löschen`
|
||||
: `Delete here AND in ${list.externalProvider}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListToDelete(null);
|
||||
} catch (err) { console.error(err); }
|
||||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Delete</button>
|
||||
</div>
|
||||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>
|
||||
{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||
</button>
|
||||
<button onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
|
||||
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||||
setListToDelete(null);
|
||||
} catch (err) { console.error(err); }
|
||||
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>
|
||||
{profile.language === 'de' ? 'Löschen' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -78,6 +78,7 @@ export async function readCachedEvents(
|
||||
calendarId: row.calendarId,
|
||||
calendarTitle: row.calendarTitle,
|
||||
calendarColor: row.calendarColor,
|
||||
busyStatus: row.busyStatus ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -128,6 +129,7 @@ export async function refreshConnectionCache(
|
||||
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
||||
endDate: ev.end.date ?? null,
|
||||
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
|
||||
busyStatus: ev.busyStatus ?? null,
|
||||
weekStart,
|
||||
syncedAt: now,
|
||||
}));
|
||||
@ -183,6 +185,7 @@ export async function upsertCachedEvent(
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
endDate: event.end.date ?? null,
|
||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||
busyStatus: event.busyStatus ?? null,
|
||||
weekStart,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
@ -200,6 +203,7 @@ export async function upsertCachedEvent(
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
endDate: event.end.date ?? null,
|
||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||
busyStatus: event.busyStatus ?? null,
|
||||
weekStart,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
|
||||
@ -863,8 +863,13 @@ export const createCalendarEvent = async (
|
||||
source: 'outlook',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
isRecurring: !!event.recurrence,
|
||||
recurringEventId: event.recurrence ? createdEvent.id : undefined,
|
||||
isRecurring: createdEvent.isRecurring ?? !!event.recurrence,
|
||||
recurringEventId: createdEvent.recurringEventId ?? (event.recurrence ? createdEvent.id : undefined),
|
||||
reminders: createdEvent.reminders,
|
||||
busyStatus: createdEvent.busyStatus as BusyStatus | undefined,
|
||||
visibility: createdEvent.visibility as EventVisibility | undefined,
|
||||
attendees: createdEvent.attendees as EventAttendee[] | undefined,
|
||||
url: createdEvent.htmlLink,
|
||||
} as CalendarEvent;
|
||||
} else if (connection.provider === 'apple') {
|
||||
const [email, appPassword] = connection.accessToken.split(':');
|
||||
@ -1114,6 +1119,13 @@ export const updateCalendarEvent = async (
|
||||
source: 'outlook',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
isRecurring: updatedEvent.isRecurring,
|
||||
recurringEventId: updatedEvent.recurringEventId,
|
||||
reminders: updatedEvent.reminders,
|
||||
busyStatus: updatedEvent.busyStatus as BusyStatus | undefined,
|
||||
visibility: updatedEvent.visibility as EventVisibility | undefined,
|
||||
attendees: updatedEvent.attendees as EventAttendee[] | undefined,
|
||||
url: updatedEvent.htmlLink,
|
||||
} as CalendarEvent;
|
||||
} else if (connection.provider === 'apple') {
|
||||
const [email, appPassword] = connection.accessToken.split(':');
|
||||
@ -1342,6 +1354,16 @@ export const deleteCalendarEvent = async (
|
||||
} else if (deleteMode === 'all' || !hasInstanceId) {
|
||||
// Delete the entire series (use series master ID)
|
||||
await deleteOutlookEvent(accessToken, calendarId, seriesMasterId);
|
||||
// Safety net: Outlook sometimes leaves the first occurrence as an orphan
|
||||
// after deleting the seriesMaster (especially when the master's start
|
||||
// matches the first occurrence). Explicitly delete the instance ID too.
|
||||
if (hasInstanceId && instanceId && instanceId !== seriesMasterId) {
|
||||
try {
|
||||
await deleteOutlookEvent(accessToken, calendarId, instanceId);
|
||||
} catch (e) {
|
||||
// Ignore — the master delete is the authoritative operation.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 'future' or 'past' — Outlook doesn't support partial series delete easily
|
||||
// Fall back to deleting the series
|
||||
|
||||
@ -147,6 +147,22 @@ export const deleteGoogleTask = async (client: OAuth2Client, taskListId: string,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a Google Tasks list.
|
||||
* Returns silently on 404 (already deleted).
|
||||
*/
|
||||
export const deleteGoogleTaskList = async (client: OAuth2Client, taskListId: string): Promise<void> => {
|
||||
const service = google.tasks({ version: 'v1', auth: client });
|
||||
try {
|
||||
await service.tasklists.delete({ tasklist: taskListId });
|
||||
} catch (error: any) {
|
||||
const status = error?.code || error?.response?.status;
|
||||
if (status === 404 || status === 410) return;
|
||||
console.error(`Error deleting Google Task list ${taskListId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch tasks from a specific list including completed ones (for sync)
|
||||
*/
|
||||
|
||||
@ -87,6 +87,32 @@ export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTo
|
||||
return (data.value || []) as MicrosoftTodoList[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a Microsoft To-Do task list.
|
||||
*/
|
||||
export const deleteMsTodoList = async (
|
||||
accessToken: string,
|
||||
listId: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 404/410 mean it's already gone — treat as success
|
||||
if (response.status === 404 || response.status === 410) return;
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Failed to delete To-Do list: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch active tasks from a specific To-Do list (for import).
|
||||
*/
|
||||
@ -168,6 +194,7 @@ export const createMsTodoTask = async (
|
||||
title: string;
|
||||
body?: string;
|
||||
dueDateTime?: string;
|
||||
importance?: 'low' | 'normal' | 'high';
|
||||
}
|
||||
): Promise<MicrosoftTodoTask> => {
|
||||
const requestBody: any = { title: taskData.title };
|
||||
@ -181,6 +208,9 @@ export const createMsTodoTask = async (
|
||||
timeZone: 'UTC'
|
||||
};
|
||||
}
|
||||
if (taskData.importance) {
|
||||
requestBody.importance = taskData.importance;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
|
||||
@ -214,6 +244,7 @@ export const updateMsTodoTask = async (
|
||||
body?: string;
|
||||
status?: 'notStarted' | 'completed';
|
||||
dueDateTime?: string | null;
|
||||
importance?: 'low' | 'normal' | 'high';
|
||||
}
|
||||
): Promise<MicrosoftTodoTask> => {
|
||||
const body: any = {};
|
||||
@ -236,6 +267,7 @@ export const updateMsTodoTask = async (
|
||||
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
|
||||
: null;
|
||||
}
|
||||
if (updates.importance !== undefined) body.importance = updates.importance;
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
|
||||
@ -201,54 +201,7 @@ export const getUpcomingEvents = async (
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.value.map((event: any) => {
|
||||
// Map Outlook showAs to our busyStatus
|
||||
const showAsMap: Record<string, string> = {
|
||||
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
|
||||
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
|
||||
};
|
||||
// Map Outlook sensitivity to our visibility
|
||||
const sensitivityMap: Record<string, string> = {
|
||||
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
|
||||
};
|
||||
|
||||
// Outlook returns dateTime without Z suffix even when timeZone is UTC.
|
||||
// Append Z so JS Date parsing treats it as UTC (not local time).
|
||||
const fixUtc = (dt: string, tz: string) =>
|
||||
dt && tz === 'UTC' && !dt.endsWith('Z') ? dt + 'Z' : dt;
|
||||
|
||||
return {
|
||||
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
|
||||
summary: event.subject,
|
||||
description: event.body?.content || event.bodyPreview,
|
||||
start: {
|
||||
dateTime: fixUtc(event.start.dateTime, event.start.timeZone),
|
||||
timeZone: event.start.timeZone
|
||||
},
|
||||
end: {
|
||||
dateTime: fixUtc(event.end.dateTime, event.end.timeZone),
|
||||
timeZone: event.end.timeZone
|
||||
},
|
||||
location: event.location?.displayName,
|
||||
htmlLink: event.webLink,
|
||||
allDay: event.isAllDay,
|
||||
recurringEventId: event.seriesMasterId || undefined,
|
||||
isRecurring: event.type === 'occurrence' || event.type === 'exception' || event.type === 'seriesMaster',
|
||||
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
|
||||
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
|
||||
: undefined,
|
||||
busyStatus: showAsMap[event.showAs] || undefined,
|
||||
visibility: sensitivityMap[event.sensitivity] || undefined,
|
||||
attendees: event.attendees?.map((a: any) => ({
|
||||
email: a.emailAddress?.address,
|
||||
displayName: a.emailAddress?.name,
|
||||
responseStatus: a.status?.response === 'accepted' ? 'accepted'
|
||||
: a.status?.response === 'declined' ? 'declined'
|
||||
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
|
||||
: 'needsAction',
|
||||
})),
|
||||
};
|
||||
});
|
||||
return data.value.map((event: any) => mapOutlookEventResponse(event));
|
||||
};
|
||||
|
||||
const ensureTimeZone = (dateTimeObj: any) => {
|
||||
@ -283,6 +236,45 @@ const normalizeOutlookDateTime = (dtObj: any) => {
|
||||
return { dateTime: dt, timeZone: dtObj.timeZone };
|
||||
};
|
||||
|
||||
// Map a raw Microsoft Graph event response into our internal shape.
|
||||
// Used after create/update to keep the full set of fields (busyStatus, visibility,
|
||||
// attendees, reminders, recurrence info) flowing back to the cache + UI.
|
||||
const mapOutlookEventResponse = (ev: any) => {
|
||||
const showAsMap: Record<string, string> = {
|
||||
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
|
||||
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
|
||||
};
|
||||
const sensitivityMap: Record<string, string> = {
|
||||
'normal': 'default', 'personal': 'default',
|
||||
'private': 'private', 'confidential': 'confidential',
|
||||
};
|
||||
return {
|
||||
id: ev.seriesMasterId ? `${ev.seriesMasterId}::${ev.id}` : ev.id,
|
||||
summary: ev.subject,
|
||||
description: ev.body?.content || ev.bodyPreview,
|
||||
start: normalizeOutlookDateTime(ev.start),
|
||||
end: normalizeOutlookDateTime(ev.end),
|
||||
location: ev.location?.displayName,
|
||||
htmlLink: ev.webLink,
|
||||
allDay: ev.isAllDay,
|
||||
recurringEventId: ev.seriesMasterId || undefined,
|
||||
isRecurring: ev.type === 'occurrence' || ev.type === 'exception' || ev.type === 'seriesMaster',
|
||||
reminders: ev.isReminderOn && ev.reminderMinutesBeforeStart != null
|
||||
? [{ method: 'popup', minutes: ev.reminderMinutesBeforeStart }]
|
||||
: undefined,
|
||||
busyStatus: showAsMap[ev.showAs] || undefined,
|
||||
visibility: sensitivityMap[ev.sensitivity] || undefined,
|
||||
attendees: ev.attendees?.map((a: any) => ({
|
||||
email: a.emailAddress?.address,
|
||||
displayName: a.emailAddress?.name,
|
||||
responseStatus: a.status?.response === 'accepted' ? 'accepted'
|
||||
: a.status?.response === 'declined' ? 'declined'
|
||||
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
|
||||
: 'needsAction',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const createEvent = async (
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
@ -336,15 +328,7 @@ export const createEvent = async (
|
||||
}
|
||||
|
||||
const created = await response.json();
|
||||
return {
|
||||
id: created.id,
|
||||
summary: created.subject,
|
||||
description: created.bodyPreview,
|
||||
start: normalizeOutlookDateTime(created.start),
|
||||
end: normalizeOutlookDateTime(created.end),
|
||||
location: created.location?.displayName,
|
||||
allDay: created.isAllDay
|
||||
};
|
||||
return mapOutlookEventResponse(created);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -419,15 +403,7 @@ export const updateEvent = async (
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
return {
|
||||
id: updated.id,
|
||||
summary: updated.subject,
|
||||
description: updated.bodyPreview,
|
||||
start: normalizeOutlookDateTime(updated.start),
|
||||
end: normalizeOutlookDateTime(updated.end),
|
||||
location: updated.location?.displayName,
|
||||
allDay: updated.isAllDay
|
||||
};
|
||||
return mapOutlookEventResponse(updated);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user