Merge pull request #1089 from MBombeck/feat/manual-workout-entry

Log a workout by hand
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-02 23:59:11 +02:00
commit 2473d52f19
31 files changed
+2579 -61

No files matched your search

+109 -1
View File
@@ -21,13 +21,21 @@
* route geometry); absent for manual entries.
* - Ownership-gated: a row owned by another user resolves as 404 so
* the existence channel never leaks.
*
* `DELETE /api/workouts/{id}` removes a workout entered by hand. Only a
* `MANUAL` row: a synced workout would come back with the next sync, so
* deleting it here would be a promise the record cannot keep. See the
* handler below.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
@@ -357,3 +365,103 @@ export const GET = apiHandler(
});
},
);
/**
* `DELETE /api/workouts/{id}` — remove a workout that was entered by hand.
*
* - Own record only: `requireAuth()` with no scope admits a cookie session or
* a wildcard token and refuses an acting-account carrier, the same arm the
* detail read above uses. A narrow token is refused.
* - Ownership: another user's row answers 404, like the read.
* - `MANUAL` only. Every other source is a sync, and the next sync would
* write the row back, so it answers 409 instead of pretending.
* - Not module-gated: deleting is cleanup of the person's own rows, which
* the data layer allows whatever the module says
* (`module-route-gate-inventory.test.ts`).
*
* The workout table carries no tombstone, so the row is removed outright;
* route, samples, insight and claim rows cascade with it. A personal record
* the workout set would otherwise outlive it (the detector only ever raises
* a stored best), so the records keyed on this workout go in the same
* transaction and a silent detection pass re-derives the honest best.
*/
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.workout.findUnique({
where: { id },
select: {
id: true,
userId: true,
source: true,
externalId: true,
startedAt: true,
sportType: true,
},
});
if (!row || row.userId !== user.id) {
return apiError("Workout not found", 404);
}
if (row.source !== "MANUAL") {
annotate({
action: {
name: "workout.delete",
entity_type: "workout",
entity_id: id,
},
meta: { outcome: "synced_source", source: row.source },
});
return apiError(
"Only a workout entered by hand can be deleted here",
409,
{ errorCode: "workout.delete.synced_source" },
);
}
const removedRecords = await prisma.$transaction(async (tx) => {
const records = await tx.personalRecord.deleteMany({
where: {
userId: user.id,
metricSlot: { not: null },
source: "MANUAL",
...(row.externalId !== null
? { externalId: row.externalId }
: { externalId: null, achievedAt: row.startedAt }),
},
});
await tx.workout.delete({ where: { id: row.id } });
return records.count;
});
await auditLog("workout.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
workoutId: id,
sportType: row.sportType,
personalRecordsRemoved: removedRecords,
},
});
annotate({
action: { name: "workout.delete", entity_type: "workout", entity_id: id },
meta: { outcome: "deleted", personalRecordsRemoved: removedRecords },
});
invalidateUserMeasurements(user.id, { evict: true });
if (removedRecords > 0) {
try {
await enqueuePrDetection(user.id, { silent: true });
} catch (err) {
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
return apiSuccess({ deleted: true });
},
);
@@ -0,0 +1,189 @@
/**
* Unit suite for `DELETE /api/workouts/{id}`.
*
* Pins:
* - a workout entered by hand is removed, with the personal records keyed
* on it, and a silent detection pass re-derives the previous best;
* - a synced workout answers 409 and nothing is touched, because the next
* sync would write it back;
* - another user's row and a missing row answer 404;
* - no session answers 401.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const tx = {
personalRecord: { deleteMany: vi.fn() },
workout: { delete: vi.fn() },
};
vi.mock("@/lib/db", () => ({
prisma: {
workout: { findUnique: vi.fn() },
$transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MANUAL_ROW = {
id: "w-1",
userId: "user-1",
source: "MANUAL" as const,
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
startedAt: new Date("2026-09-01T07:00:00Z"),
sportType: "running",
};
function del(id = "w-1") {
return DELETE(
new NextRequest(`http://localhost/api/workouts/${id}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id }) },
);
}
describe("DELETE /api/workouts/{id}", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 });
tx.workout.delete.mockResolvedValue({ id: "w-1" });
});
it("removes a workout entered by hand", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
const res = await del();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } });
expect(auditLog).toHaveBeenCalledWith(
"workout.delete",
expect.objectContaining({ userId: "user-1" }),
);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("takes the personal records the workout set with it and re-derives the best", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 });
const res = await del();
expect(res.status).toBe(200);
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
metricSlot: { not: null },
source: "MANUAL",
externalId: MANUAL_ROW.externalId,
},
});
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
it("keys a record without an external id on the session's start", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
externalId: null,
} as never);
await del();
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
metricSlot: { not: null },
source: "MANUAL",
externalId: null,
achievedAt: MANUAL_ROW.startedAt,
},
});
// Nothing was removed, so nothing needs re-deriving.
expect(enqueuePrDetection).not.toHaveBeenCalled();
});
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
"refuses a %s workout with 409 and touches nothing",
async (source) => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
source,
} as never);
const res = await del();
expect(res.status).toBe(409);
const body = await res.json();
expect(body.data).toBeNull();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.workout.delete).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
},
);
it("answers 404 for another user's workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
userId: "someone-else",
} as never);
const res = await del();
expect(res.status).toBe(404);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("answers 404 when there is no such workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(null);
const res = await del("missing");
expect(res.status).toBe(404);
});
it("answers 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await del();
expect(res.status).toBe(401);
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
});
+6
View File
@@ -10,6 +10,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route";
import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button";
import {
WorkoutDetailHeader,
WorkoutDetailStats,
@@ -64,6 +65,11 @@ export default function InsightsWorkoutDetailPage({
// No description. The one this page used to carry belongs to the LIST
// ("Recent runs, rides, walks…, deduped…") and describes a surface
// this is not — a detail page must not claim to be the list.
// A workout entered by hand can be deleted here; the control renders
// nothing for a synced one.
headerAction={
data && !canonicalHref ? <DeleteWorkoutButton workout={data} /> : null
}
backLink={
<BackLink
href="/insights/workouts"
+41 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { Activity } from "lucide-react";
import { Activity, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -10,6 +10,11 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { canLogWorkout } from "@/lib/workouts/manual-entry";
import { QueryErrorCard } from "@/components/ui/query-error-card";
/**
@@ -42,6 +47,10 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
export default function InsightsWorkoutsPageClient() {
const { t, tCount } = useTranslations();
const [sportType, setSportType] = useState<string | undefined>(undefined);
const [logOpen, setLogOpen] = useState(false);
const { user } = useAuth();
const capabilities = useRecordCapabilities();
const showLog = canLogWorkout(capabilities, user?.modules);
const {
workouts,
total,
@@ -80,7 +89,31 @@ export default function InsightsWorkoutsPageClient() {
description={t("insights.workouts.description")}
explainerMetric="workouts"
coachLaunch
headerAction={
showLog ? (
// Same 40 px ghost icon and widened hit area as the header
// cluster's other actions; it opens the form in place.
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-log-manual"
aria-label={t("insights.workouts.manual.logWorkout")}
title={t("insights.workouts.manual.logWorkout")}
onClick={() => setLogOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
) : null
}
>
{showLog ? (
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
) : null}
{/* No `<MetricRangeControls>` here: workouts are session records, not a
MeasurementType series, so the period-over-period range read has
nothing to aggregate. */}
@@ -100,7 +133,13 @@ export default function InsightsWorkoutsPageClient() {
icon={<Activity className="size-6" />}
title={t("insights.workouts.emptyState.title")}
description={t("insights.workouts.emptyState.description")}
cta={null}
cta={
showLog ? (
<Button size="sm" type="button" onClick={() => setLogOpen(true)}>
{t("insights.workouts.manual.logWorkout")}
</Button>
) : null
}
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
/>
) : (