mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #1089 from MBombeck/feat/manual-workout-entry
Log a workout by hand
This commit is contained in:
31 files changed
+2579
-61
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user