Pure helpers for the Gantt's drag/double-click math: - fractionToDate(fraction, range): what ISO date a fractional position in the chart represents, snapped to whole UTC days. - shiftDatesByFraction: move a bar's start and target by the same delta, clamped to the visible range, with start dropped when clamping would invert or collapse the span. - resizeEndToFraction: what the new target date becomes when the user drags a right edge. - moveCardByFraction: alias that produces the action's input shape. 11 unit tests cover the corners (no dates set, point bar, clamping past the right edge, inversion guard). rescheduleCardAction also drops its owner-only gate. Any signed-in user — owner or client manager — can move dates now. PocketBase's existing row-level rules govern which cards a given user can write (a client manager still only writes their own client's cards); the activity-log row is unchanged and attributes the move to the actual user either way. Adds setSubtaskDatesAction so the same drag/double-click gestures work on subtask rows. No audit log on subtasks — matches the existing pattern (subtask toggles don't log).
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
import { getServerPb } from "@/lib/pb/server";
|
|
import { getAdminPb } from "@/lib/pb/admin";
|
|
import { requireSession } from "@/lib/auth/session";
|
|
import { writeCardWithAudit } from "@/lib/cards/write-with-audit";
|
|
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
|
|
import {
|
|
planReschedule,
|
|
validateRescheduleInput,
|
|
} from "@/app/actions/reschedule-helpers";
|
|
import type { ActivityAction } from "@/lib/pb/types";
|
|
|
|
/**
|
|
* Reschedule a card from the planning view: drag-to-move, drag-right-edge
|
|
* to resize, double-click-to-edit, or unschedule (date === null). Available
|
|
* to any signed-in user — the owner, the client manager for their own
|
|
* client's cards — because the activity row makes every change visible to
|
|
* whoever owns the card.
|
|
*
|
|
* `target_date` is a CLIENT-OWNED field on `cards`. Both owners and client
|
|
* managers legitimately write it: owners when rescheduling the work, client
|
|
* managers when adjusting a date they originally set. PocketBase's row-level
|
|
* rules (Phase 1) restrict a client manager to their own client's cards, so
|
|
* the call below only succeeds when the caller's token can read the card —
|
|
* the existing security boundary.
|
|
*
|
|
* The activity row records actor = the signed-in user, so the card history
|
|
* reads "Bob moved the target date from 1 Oct to 15 Nov" in the right
|
|
* direction regardless of who triggered the move.
|
|
*
|
|
* `date === null` clears the card's `target_date` ("unschedule"); the same
|
|
* audit-row shape applies, only the patch payload differs.
|
|
*
|
|
* Resize support: passing `startDate` is how a drag converts a point bar into
|
|
* a span. From the second resize onward the existing `start_date` is reused.
|
|
*/
|
|
export async function rescheduleCardAction(input: {
|
|
cardId: string;
|
|
date: string | null;
|
|
/**
|
|
* Optional explicit `start_date` write.
|
|
* - omitted → leave `start_date` untouched (move/unschedule use case).
|
|
* - "" → clear `start_date` (currently no caller uses this, but
|
|
* it keeps the surface symmetric with `date`).
|
|
* - "YYYY-MM-DD" → set `start_date` to this value. The Timeline view
|
|
* uses this when converting a point bar into a span bar.
|
|
*/
|
|
startDate?: string | null;
|
|
}): Promise<{ ok: boolean; message?: string }> {
|
|
const session = await requireSession();
|
|
|
|
const validationError = validateRescheduleInput(input);
|
|
if (validationError) return { ok: false, message: validationError };
|
|
|
|
const pb = await getServerPb();
|
|
|
|
const card = await pb
|
|
.collection("cards")
|
|
.getOne<CardRecord>(input.cardId)
|
|
.catch(() => null);
|
|
if (!card) return { ok: false, message: "Card not found" };
|
|
|
|
const column = await pb
|
|
.collection("columns")
|
|
.getOne<ColumnRecord>(card.column)
|
|
.catch(() => null);
|
|
if (column?.is_done) {
|
|
return { ok: false, message: "Card is done" };
|
|
}
|
|
|
|
const { plan, noop } = planReschedule({
|
|
cardId: card.id,
|
|
currentTargetDate: card.target_date ?? "",
|
|
currentStartDate: card.start_date ?? "",
|
|
date: input.date,
|
|
startDate: input.startDate,
|
|
});
|
|
|
|
if (noop) return { ok: true };
|
|
|
|
// The `activity` collection's createRule is null (superuser-only). Pass
|
|
// the admin PB for the row inserts; the card update uses the caller's
|
|
// own token, which is what enforces PocketBase's record-level rules.
|
|
const adminPb = await getAdminPb();
|
|
|
|
const rows = plan.rows.map((row) => ({
|
|
project: card.project,
|
|
card: card.id,
|
|
actor: session.userId,
|
|
action: "date_changed" as ActivityAction,
|
|
field: row.field,
|
|
old_value: row.old_value,
|
|
new_value: row.new_value,
|
|
}));
|
|
|
|
await writeCardWithAudit(pb, { cardId: card.id, patch: plan.patch, rows }, adminPb);
|
|
|
|
revalidatePath("/plan");
|
|
return { ok: true };
|
|
}
|