kanban-dev-webapp/src/app/actions/reschedule.ts
Dave 2b01d0e837 feat(plan): bar-math helpers + open reschedule to anyone signed in
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).
2026-09-22 05:16:04 +02:00

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 };
}