199 lines
7.1 KiB
TypeScript
199 lines
7.1 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { requireUserId } from "@/lib/auth-helpers";
|
|
import { boardPath, projectAccessFilter, scheduledTodoAccessFilter } from "@/lib/access";
|
|
import {
|
|
RecurrenceInputSchema,
|
|
ScheduledTodoDetailsSchema,
|
|
ScheduledTodoRemindMinutesBeforeSchema,
|
|
ScheduledTodoTimeDueSchema,
|
|
ScheduledTodoTitleSchema,
|
|
type RecurrenceInput,
|
|
} from "@/lib/validation/scheduled-todo";
|
|
import { buildRRuleString, parseRRuleString } from "@/lib/rrule-utils";
|
|
import { getScheduledTodoBoard } from "@/lib/scheduled-todos";
|
|
import type { ScheduledBoardDTO, ScheduledTodoEditDTO } from "@/types/scheduled-todo";
|
|
|
|
/** Verifies the project (Home scope is always allowed for its own user). */
|
|
async function assertProjectAccess(userId: string, projectId: string) {
|
|
const project = await prisma.project.findFirst({
|
|
where: { id: projectId, ...projectAccessFilter(userId) },
|
|
select: { id: true },
|
|
});
|
|
if (!project) throw new Error("Project not found");
|
|
}
|
|
|
|
async function findOwnedScheduledTodo(userId: string, id: string) {
|
|
const todo = await prisma.scheduledTodo.findFirst({
|
|
where: { id, ...scheduledTodoAccessFilter(userId) },
|
|
// timeDue is included so updateScheduledTodo can tell whether a
|
|
// reminder is still meaningful even when this particular call doesn't
|
|
// touch timeDue itself.
|
|
select: { id: true, projectId: true, timeDue: true },
|
|
});
|
|
if (!todo) throw new Error("Scheduled to-do not found");
|
|
return todo;
|
|
}
|
|
|
|
/** The action the client Scheduled panel calls -- it derives Home vs.
|
|
* Project scope from the current route itself (see ScheduledPanel), so this
|
|
* is the single entry point regardless of which board is showing. */
|
|
export async function getScheduledBoard(projectId: string | null): Promise<ScheduledBoardDTO> {
|
|
const userId = await requireUserId();
|
|
if (projectId) {
|
|
await assertProjectAccess(userId, projectId);
|
|
return getScheduledTodoBoard({ projectId });
|
|
}
|
|
return getScheduledTodoBoard({ userId, projectId: null });
|
|
}
|
|
|
|
export async function getScheduledTodoForEdit(id: string): Promise<ScheduledTodoEditDTO> {
|
|
const userId = await requireUserId();
|
|
const todo = await prisma.scheduledTodo.findFirst({
|
|
where: { id, ...scheduledTodoAccessFilter(userId) },
|
|
});
|
|
if (!todo) throw new Error("Scheduled to-do not found");
|
|
|
|
return {
|
|
id: todo.id,
|
|
title: todo.title,
|
|
details: todo.details,
|
|
startDate: todo.startDate.toISOString().slice(0, 10),
|
|
recurrence: todo.rrule ? parseRRuleString(todo.rrule) : null,
|
|
timeDue: todo.timeDue,
|
|
remindMinutesBefore: todo.remindMinutesBefore,
|
|
};
|
|
}
|
|
|
|
export async function createScheduledTodo(
|
|
scope: { projectId: string | null },
|
|
data: {
|
|
title: string;
|
|
details?: string;
|
|
startDate: string;
|
|
recurrence?: RecurrenceInput;
|
|
timeDue?: string | null;
|
|
remindMinutesBefore?: number | null;
|
|
}
|
|
): Promise<void> {
|
|
const userId = await requireUserId();
|
|
const title = ScheduledTodoTitleSchema.parse(data.title);
|
|
const details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : undefined;
|
|
const recurrence = data.recurrence ? RecurrenceInputSchema.parse(data.recurrence) : undefined;
|
|
const timeDue = data.timeDue ? ScheduledTodoTimeDueSchema.parse(data.timeDue) : null;
|
|
// No reminder without a time to count down from, regardless of what's passed.
|
|
const remindMinutesBefore =
|
|
timeDue && data.remindMinutesBefore != null
|
|
? ScheduledTodoRemindMinutesBeforeSchema.parse(data.remindMinutesBefore)
|
|
: null;
|
|
|
|
if (scope.projectId) await assertProjectAccess(userId, scope.projectId);
|
|
|
|
await prisma.scheduledTodo.create({
|
|
data: {
|
|
title,
|
|
details: details ?? null,
|
|
userId: scope.projectId ? null : userId,
|
|
projectId: scope.projectId,
|
|
startDate: new Date(data.startDate),
|
|
rrule: recurrence ? buildRRuleString(recurrence) : null,
|
|
timeDue,
|
|
remindMinutesBefore,
|
|
},
|
|
});
|
|
|
|
revalidatePath(boardPath(scope.projectId));
|
|
}
|
|
|
|
export async function updateScheduledTodo(
|
|
id: string,
|
|
data: {
|
|
title?: string;
|
|
details?: string | null;
|
|
startDate?: string;
|
|
recurrence?: RecurrenceInput | null;
|
|
timeDue?: string | null;
|
|
remindMinutesBefore?: number | null;
|
|
}
|
|
): Promise<void> {
|
|
const userId = await requireUserId();
|
|
const todo = await findOwnedScheduledTodo(userId, id);
|
|
|
|
const update: {
|
|
title?: string;
|
|
details?: string | null;
|
|
startDate?: Date;
|
|
rrule?: string | null;
|
|
timeDue?: string | null;
|
|
remindMinutesBefore?: number | null;
|
|
} = {};
|
|
if (data.title !== undefined) update.title = ScheduledTodoTitleSchema.parse(data.title);
|
|
if (data.details !== undefined) {
|
|
update.details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : null;
|
|
}
|
|
if (data.startDate !== undefined) update.startDate = new Date(data.startDate);
|
|
if (data.recurrence !== undefined) {
|
|
update.rrule = data.recurrence ? buildRRuleString(RecurrenceInputSchema.parse(data.recurrence)) : null;
|
|
}
|
|
if (data.timeDue !== undefined) {
|
|
update.timeDue = data.timeDue ? ScheduledTodoTimeDueSchema.parse(data.timeDue) : null;
|
|
}
|
|
// The time due this reminder would count down from, after this update --
|
|
// whatever this call sets it to, or (if this call doesn't touch it) the
|
|
// value already on the row. No reminder without one, regardless of what's
|
|
// passed.
|
|
const effectiveTimeDue = data.timeDue !== undefined ? update.timeDue : todo.timeDue;
|
|
if (data.remindMinutesBefore !== undefined) {
|
|
update.remindMinutesBefore =
|
|
effectiveTimeDue && data.remindMinutesBefore != null
|
|
? ScheduledTodoRemindMinutesBeforeSchema.parse(data.remindMinutesBefore)
|
|
: null;
|
|
} else if (!effectiveTimeDue && todo.timeDue) {
|
|
// Time due was just cleared and this call didn't say anything about
|
|
// the reminder -- don't leave a stale reminder pointing at nothing.
|
|
update.remindMinutesBefore = null;
|
|
}
|
|
|
|
await prisma.scheduledTodo.update({ where: { id }, data: update });
|
|
|
|
revalidatePath(boardPath(todo.projectId));
|
|
}
|
|
|
|
export async function deleteScheduledTodo(id: string): Promise<void> {
|
|
const userId = await requireUserId();
|
|
const todo = await findOwnedScheduledTodo(userId, id);
|
|
|
|
await prisma.scheduledTodo.delete({ where: { id } });
|
|
|
|
revalidatePath(boardPath(todo.projectId));
|
|
}
|
|
|
|
/** Toggles just one date's occurrence -- a recurring to-do's other dates
|
|
* are unaffected (see ScheduledTodoCompletion). */
|
|
export async function toggleScheduledOccurrence(
|
|
scheduledTodoId: string,
|
|
occurrenceDate: string,
|
|
completed: boolean
|
|
): Promise<void> {
|
|
const userId = await requireUserId();
|
|
const todo = await findOwnedScheduledTodo(userId, scheduledTodoId);
|
|
const date = new Date(occurrenceDate);
|
|
|
|
if (completed) {
|
|
await prisma.scheduledTodoCompletion.upsert({
|
|
where: { scheduledTodoId_occurrenceDate: { scheduledTodoId, occurrenceDate: date } },
|
|
create: { scheduledTodoId, occurrenceDate: date },
|
|
update: {},
|
|
});
|
|
} else {
|
|
await prisma.scheduledTodoCompletion.deleteMany({
|
|
where: { scheduledTodoId, occurrenceDate: date },
|
|
});
|
|
}
|
|
|
|
revalidatePath(boardPath(todo.projectId));
|
|
}
|