Sessions UI, CSV parser tests, route tests, composite indexes, verify-db action
Per-user sessions UI (Settings -> Active sessions) - listMySessions returns the current user's still-valid sessions with last-8-char token suffix (UX hint) and an isCurrent flag (the authoritative "this device" marker). - revokeSession refuses if the target is the actor's current token — use Sign out for that flow. Per-row Revoke button on every other. - revokeAllOtherSessions = the previously-internal `deleteOtherSessions` helper exposed as a single button "Sign out other devices". - All gated to the actor's own userId (never lets a user touch another user's sessions). CSV parser refactor + tests - Extracted parseCSV, NAME_MAP, parseFloatMaybe, parseIntMaybe, getVariationNote, resolveExerciseName, parseDate from app/api/import/parse/route.ts to lib/csvParser.ts. Behavior byte-identical; route is now a thin wrapper that imports from the lib. - 18 tests covering: empty input, simple rows, lowercased headers, quoted-field commas, escaped double quotes, CRLF normalization, empty-line handling; numeric maybe-parsers; getVariationNote known patterns + null pass-through; ALL 27 NAME_MAP entries map to their canonical target; named CSV-shorthand examples; M/D/YYYY + ISO date parsing with noon-UTC anchoring (so US negative-offset zones still see the same calendar day). Workout + exercise CRUD route tests - New tests/routes-crud.test.ts: GET/POST /api/exercises, GET/POST /api/workouts. 401 on unauthenticated, per-user data isolation, query filtering, soft-delete exclusion, isCustom stamping, duplicate detection, type-driven inputFields defaults (cardio gets duration+calories), Zod validation rejection, set creation with weight/reps/rpe persisted, negative-reps rejected. - Helper builds NextRequest objects so the routes' nextUrl.searchParams access works. Composite indexes for hot query paths (schema.prisma + entrypoint) - Session: (userId, expiresAt) for "list my still-valid sessions" and per-user cleanup. - Workout: (userId, deletedAt, date) for the workout list query (filter by user + alive + date order). - SetLog: (workoutId, setNumber) for the always-ordered set fetch under each workout. - Existing single-column indexes kept; composites are additive. - Entrypoint runs CREATE INDEX IF NOT EXISTS so live snapshots pick up the new indexes on first boot after upgrade. verify-database StartOS action (start9/0.4/startos/actions/verifyDatabase.ts) - Read-only. Runs PRAGMA integrity_check + quick_check + row-count queries against /data/app.db, reports as a structured result. - allowedStatuses: only-running. Mounts the volume read-only. - Use after a StartOS Backup, after a host crash, or after a fresh sideload to confirm the data is sound before relying on it. Test suite now 67 tests across 7 files in ~2.4s.
This commit is contained in:
@@ -1,37 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
// Exercise name mapping - CSV shorthand to DB names
|
parseCSV,
|
||||||
const NAME_MAP: Record<string, string> = {
|
parseFloatMaybe,
|
||||||
"Ab Wheel": "Ab Wheel Rollout",
|
parseIntMaybe,
|
||||||
"BB Upright Row": "Upright Row",
|
getVariationNote,
|
||||||
"Ball Situp": "Exercise Ball Situp",
|
resolveExerciseName,
|
||||||
"Bench": "Bench Press",
|
parseDate,
|
||||||
"DB Lateral Raise": "Lateral Raise",
|
} from "@/lib/csvParser";
|
||||||
"Dip": "Dips (Chest)",
|
|
||||||
"Face Pull": "Face Pulls",
|
|
||||||
"SA Lat Pulldown": "Lat Pulldown",
|
|
||||||
"SL Calf Raise": "Calf Raise",
|
|
||||||
"BB Row": "Barbell Row",
|
|
||||||
"DB Row": "Dumbbell Row",
|
|
||||||
"GHD": "Glute ham developer",
|
|
||||||
"Hamstring DL": "Hamstring deadlift",
|
|
||||||
"BB Curl": "Barbell Curl",
|
|
||||||
"BB Hip Bridge": "Hip Thrust",
|
|
||||||
"Cable Trap": "Rear delt",
|
|
||||||
"Chinup (Narrow)": "Chinup",
|
|
||||||
"Chinup Negatives": "Chinup",
|
|
||||||
"Squat (Foot Elevated)": "Squat",
|
|
||||||
"Ball Bicep Curl": "Dumbbell Curl",
|
|
||||||
"KB Hip Flexor": "Hip Flexor",
|
|
||||||
"Hamstring Deadlift": "Hamstring deadlift",
|
|
||||||
"Shoulder Press": "Overhead Press",
|
|
||||||
"CoC": "Captains of Crush",
|
|
||||||
"Hex DL": "Hex Bar Deadlift",
|
|
||||||
"KB Extension": "Kettlebell Leg Extension",
|
|
||||||
"Ski": "SkiErg",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ParsedSet {
|
interface ParsedSet {
|
||||||
setNumber: number;
|
setNumber: number;
|
||||||
@@ -65,121 +42,6 @@ interface ParseResponse {
|
|||||||
unmapped: string[];
|
unmapped: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCSV(content: string): Array<Record<string, string>> {
|
|
||||||
const text = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
||||||
if (!text.trim()) return [];
|
|
||||||
|
|
||||||
const parsedRows: string[][] = [];
|
|
||||||
let row: string[] = [];
|
|
||||||
let cell = "";
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const ch = text[i];
|
|
||||||
const next = text[i + 1];
|
|
||||||
|
|
||||||
if (ch === "\"") {
|
|
||||||
if (inQuotes && next === "\"") {
|
|
||||||
cell += "\"";
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ch === "," && !inQuotes) {
|
|
||||||
row.push(cell);
|
|
||||||
cell = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ch === "\n" && !inQuotes) {
|
|
||||||
row.push(cell);
|
|
||||||
if (row.some((v) => v.trim() !== "")) {
|
|
||||||
parsedRows.push(row);
|
|
||||||
}
|
|
||||||
row = [];
|
|
||||||
cell = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cell += ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
row.push(cell);
|
|
||||||
if (row.some((v) => v.trim() !== "")) {
|
|
||||||
parsedRows.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedRows.length === 0) return [];
|
|
||||||
|
|
||||||
const header = parsedRows[0].map((h) => h.trim().toLowerCase());
|
|
||||||
const rows: Array<Record<string, string>> = [];
|
|
||||||
|
|
||||||
for (let i = 1; i < parsedRows.length; i++) {
|
|
||||||
const values = parsedRows[i];
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
header.forEach((col, idx) => {
|
|
||||||
const value = (values[idx] || "").trim();
|
|
||||||
if (value !== "") {
|
|
||||||
out[col] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (Object.keys(out).length > 0) {
|
|
||||||
rows.push(out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFloatMaybe(value?: string): number | undefined {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const n = Number.parseFloat(value);
|
|
||||||
return Number.isFinite(n) ? n : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIntMaybe(value?: string): number | undefined {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const n = Number.parseInt(value, 10);
|
|
||||||
return Number.isFinite(n) ? n : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVariationNote(originalName: string): string | null {
|
|
||||||
if (originalName.includes("Narrow")) return "narrow";
|
|
||||||
if (originalName.includes("Negatives")) return "negatives";
|
|
||||||
if (originalName.includes("Foot Elevated")) return "foot elevated";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveExerciseName(csvName: string): string {
|
|
||||||
// Check if it's in the name map
|
|
||||||
if (NAME_MAP[csvName]) {
|
|
||||||
return NAME_MAP[csvName];
|
|
||||||
}
|
|
||||||
// Return as-is for direct lookup
|
|
||||||
return csvName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse dates like "1/27/2026" or "2026-01-27" into ISO date string
|
|
||||||
function parseDate(dateStr: string): string {
|
|
||||||
// Try M/D/YYYY format
|
|
||||||
const mdyMatch = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
||||||
if (mdyMatch) {
|
|
||||||
const month = mdyMatch[1].padStart(2, "0");
|
|
||||||
const day = mdyMatch[2].padStart(2, "0");
|
|
||||||
const year = mdyMatch[3];
|
|
||||||
return `${year}-${month}-${day}T12:00:00.000Z`;
|
|
||||||
}
|
|
||||||
// Try ISO format
|
|
||||||
if (dateStr.includes("-")) {
|
|
||||||
return new Date(dateStr + "T12:00:00.000Z").toISOString();
|
|
||||||
}
|
|
||||||
// Fallback
|
|
||||||
return new Date(dateStr).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import SettingsForm from "@/components/settings/SettingsForm";
|
import SettingsForm from "@/components/settings/SettingsForm";
|
||||||
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
||||||
|
import SessionsList from "@/components/settings/SessionsList";
|
||||||
import ExportMyData from "@/components/settings/ExportMyData";
|
import ExportMyData from "@/components/settings/ExportMyData";
|
||||||
import DangerZone from "@/components/settings/DangerZone";
|
import DangerZone from "@/components/settings/DangerZone";
|
||||||
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
||||||
@@ -30,6 +31,7 @@ export default async function SettingsPage() {
|
|||||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
|
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
|
||||||
<SettingsForm user={user} />
|
<SettingsForm user={user} />
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
<SessionsList />
|
||||||
<ExportMyData />
|
<ExportMyData />
|
||||||
{user.isAdmin && instanceSettings && (
|
{user.isAdmin && instanceSettings && (
|
||||||
<AdminInstanceSettings
|
<AdminInstanceSettings
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string;
|
||||||
|
tokenSuffix: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the current user's active (unexpired) sessions. Tokens are
|
||||||
|
* hashed-by-truncation in the response: we expose only the last 8 hex
|
||||||
|
* characters so the user can identify "the one I'm on right now"
|
||||||
|
* without us having to round-trip the full opaque token to the
|
||||||
|
* browser. The current-session flag is the authoritative way to tell
|
||||||
|
* which row is "this device" — the suffix is just a UX hint.
|
||||||
|
*/
|
||||||
|
export async function listMySessions(): Promise<SessionRow[]> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const currentToken = cookieStore.get('sessionToken')?.value ?? null;
|
||||||
|
|
||||||
|
const rows = await prisma.session.findMany({
|
||||||
|
where: { userId: user.id, expiresAt: { gt: new Date() } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
token: true,
|
||||||
|
createdAt: true,
|
||||||
|
expiresAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
tokenSuffix: r.token.slice(-8),
|
||||||
|
isCurrent: r.token === currentToken,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
expiresAt: r.expiresAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a single session by id. Refused if the target id is the
|
||||||
|
* actor's current session — use `signOut` for that flow instead.
|
||||||
|
*/
|
||||||
|
export async function revokeSession(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{ success?: true; error?: string }> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return { error: 'Not signed in.' };
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const currentToken = cookieStore.get('sessionToken')?.value ?? null;
|
||||||
|
|
||||||
|
const target = await prisma.session.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
select: { userId: true, token: true },
|
||||||
|
});
|
||||||
|
if (!target) return { error: 'Session not found.' };
|
||||||
|
if (target.userId !== user.id) return { error: 'Not your session.' };
|
||||||
|
if (target.token === currentToken) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
'Cannot revoke your current session this way. Use Sign out instead.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.session.delete({ where: { id: sessionId } });
|
||||||
|
revalidatePath('/main/settings');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke every session for the current user except the current one. */
|
||||||
|
export async function revokeAllOtherSessions(): Promise<{
|
||||||
|
success?: true;
|
||||||
|
error?: string;
|
||||||
|
revoked?: number;
|
||||||
|
}> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return { error: 'Not signed in.' };
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const currentToken = cookieStore.get('sessionToken')?.value ?? null;
|
||||||
|
|
||||||
|
const result = await prisma.session.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
...(currentToken ? { NOT: { token: currentToken } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath('/main/settings');
|
||||||
|
return { success: true, revoked: result.count };
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
|
import {
|
||||||
|
listMySessions,
|
||||||
|
revokeSession,
|
||||||
|
revokeAllOtherSessions,
|
||||||
|
type SessionRow,
|
||||||
|
} from '@/app/main/settings/sessionsActions';
|
||||||
|
|
||||||
|
function relAge(iso: string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `${d}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionsList() {
|
||||||
|
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
listMySessions()
|
||||||
|
.then(setRows)
|
||||||
|
.catch((e) => setError(e.message ?? 'Failed to load sessions.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRevoke = (id: string) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
setError(null);
|
||||||
|
const res = await revokeSession(id);
|
||||||
|
if (res.error) setError(res.error);
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRevokeAll = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
setError(null);
|
||||||
|
const res = await revokeAllOtherSessions();
|
||||||
|
if (res.error) setError(res.error);
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const otherCount = (rows ?? []).filter((r) => !r.isCurrent).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4">
|
||||||
|
<header className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
Active sessions
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Each row is a browser or device with a valid session cookie.
|
||||||
|
Revoking ends that session immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{otherCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRevokeAll}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-zinc-300 uppercase tracking-wider hover:bg-zinc-800 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Sign out other devices
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows == null ? (
|
||||||
|
<p className="text-xs text-zinc-500">Loading...</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-500">No active sessions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-800 -mx-2">
|
||||||
|
{rows.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-2 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-white font-mono">
|
||||||
|
<span>...{s.tokenSuffix}</span>
|
||||||
|
{s.isCurrent && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider bg-emerald-900/50 text-emerald-300 px-1.5 py-0.5 rounded font-sans">
|
||||||
|
this device
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
Started {relAge(s.createdAt)} · Expires{' '}
|
||||||
|
{new Date(s.expiresAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!s.isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRevoke(s.id)}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-red-900 text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* CSV import parser helpers.
|
||||||
|
*
|
||||||
|
* Extracted from app/api/import/parse/route.ts so the helpers are
|
||||||
|
* testable in isolation. The route file imports from here; behavior
|
||||||
|
* is byte-identical.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** CSV shorthand -> canonical exercise name. */
|
||||||
|
export const NAME_MAP: Record<string, string> = {
|
||||||
|
'Ab Wheel': 'Ab Wheel Rollout',
|
||||||
|
'BB Upright Row': 'Upright Row',
|
||||||
|
'Ball Situp': 'Exercise Ball Situp',
|
||||||
|
Bench: 'Bench Press',
|
||||||
|
'DB Lateral Raise': 'Lateral Raise',
|
||||||
|
Dip: 'Dips (Chest)',
|
||||||
|
'Face Pull': 'Face Pulls',
|
||||||
|
'SA Lat Pulldown': 'Lat Pulldown',
|
||||||
|
'SL Calf Raise': 'Calf Raise',
|
||||||
|
'BB Row': 'Barbell Row',
|
||||||
|
'DB Row': 'Dumbbell Row',
|
||||||
|
GHD: 'Glute ham developer',
|
||||||
|
'Hamstring DL': 'Hamstring deadlift',
|
||||||
|
'BB Curl': 'Barbell Curl',
|
||||||
|
'BB Hip Bridge': 'Hip Thrust',
|
||||||
|
'Cable Trap': 'Rear delt',
|
||||||
|
'Chinup (Narrow)': 'Chinup',
|
||||||
|
'Chinup Negatives': 'Chinup',
|
||||||
|
'Squat (Foot Elevated)': 'Squat',
|
||||||
|
'Ball Bicep Curl': 'Dumbbell Curl',
|
||||||
|
'KB Hip Flexor': 'Hip Flexor',
|
||||||
|
'Hamstring Deadlift': 'Hamstring deadlift',
|
||||||
|
'Shoulder Press': 'Overhead Press',
|
||||||
|
CoC: 'Captains of Crush',
|
||||||
|
'Hex DL': 'Hex Bar Deadlift',
|
||||||
|
'KB Extension': 'Kettlebell Leg Extension',
|
||||||
|
Ski: 'SkiErg',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal RFC 4180-ish CSV parser. Supports quoted fields, embedded
|
||||||
|
* commas, embedded newlines inside quotes, and "" -> " escaping.
|
||||||
|
* First row is the header (case-folded). Returns an array of
|
||||||
|
* row-objects keyed by column name; empty cells are omitted.
|
||||||
|
*/
|
||||||
|
export function parseCSV(content: string): Array<Record<string, string>> {
|
||||||
|
const text = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
if (!text.trim()) return [];
|
||||||
|
|
||||||
|
const parsedRows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let cell = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
const next = text[i + 1];
|
||||||
|
|
||||||
|
if (ch === '"') {
|
||||||
|
if (inQuotes && next === '"') {
|
||||||
|
cell += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === ',' && !inQuotes) {
|
||||||
|
row.push(cell);
|
||||||
|
cell = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\n' && !inQuotes) {
|
||||||
|
row.push(cell);
|
||||||
|
if (row.some((v) => v.trim() !== '')) {
|
||||||
|
parsedRows.push(row);
|
||||||
|
}
|
||||||
|
row = [];
|
||||||
|
cell = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cell += ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.push(cell);
|
||||||
|
if (row.some((v) => v.trim() !== '')) {
|
||||||
|
parsedRows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedRows.length === 0) return [];
|
||||||
|
|
||||||
|
const header = parsedRows[0].map((h) => h.trim().toLowerCase());
|
||||||
|
const rows: Array<Record<string, string>> = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < parsedRows.length; i++) {
|
||||||
|
const values = parsedRows[i];
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
header.forEach((col, idx) => {
|
||||||
|
const value = (values[idx] || '').trim();
|
||||||
|
if (value !== '') {
|
||||||
|
out[col] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(out).length > 0) {
|
||||||
|
rows.push(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFloatMaybe(value?: string): number | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const n = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIntMaybe(value?: string): number | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract a variation note from a CSV exercise name like "Squat (Foot Elevated)". */
|
||||||
|
export function getVariationNote(originalName: string): string | null {
|
||||||
|
if (originalName.includes('Narrow')) return 'narrow';
|
||||||
|
if (originalName.includes('Negatives')) return 'negatives';
|
||||||
|
if (originalName.includes('Foot Elevated')) return 'foot elevated';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a CSV-shorthand exercise name to its canonical name, or pass through. */
|
||||||
|
export function resolveExerciseName(csvName: string): string {
|
||||||
|
return NAME_MAP[csvName] ?? csvName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse "M/D/YYYY" or "YYYY-MM-DD" into an ISO timestamp at noon UTC.
|
||||||
|
* Noon-UTC anchors the day to a single calendar date regardless of the
|
||||||
|
* viewer's timezone — picking 00:00 UTC would put the day on the
|
||||||
|
* previous date for any negative-offset zone.
|
||||||
|
*/
|
||||||
|
export function parseDate(dateStr: string): string {
|
||||||
|
const mdyMatch = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||||
|
if (mdyMatch) {
|
||||||
|
const month = mdyMatch[1].padStart(2, '0');
|
||||||
|
const day = mdyMatch[2].padStart(2, '0');
|
||||||
|
const year = mdyMatch[3];
|
||||||
|
return `${year}-${month}-${day}T12:00:00.000Z`;
|
||||||
|
}
|
||||||
|
if (dateStr.includes('-')) {
|
||||||
|
return new Date(dateStr + 'T12:00:00.000Z').toISOString();
|
||||||
|
}
|
||||||
|
return new Date(dateStr).toISOString();
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ model Session {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
|
// Composite for "list this user's still-valid sessions" (Settings page,
|
||||||
|
// ensure-other-sessions revocation). Also speeds the per-user session
|
||||||
|
// cleanup query in deleteOtherSessions.
|
||||||
|
@@index([userId, expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Exercise {
|
model Exercise {
|
||||||
@@ -89,6 +93,10 @@ model Workout {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@index([deletedAt])
|
@@index([deletedAt])
|
||||||
|
// Hot path: workout list page is `where userId AND deletedAt IS NULL
|
||||||
|
// ORDER BY date DESC LIMIT N`. Composite index lets SQLite walk it
|
||||||
|
// straight off the index without sorting.
|
||||||
|
@@index([userId, deletedAt, date])
|
||||||
}
|
}
|
||||||
|
|
||||||
model SetLog {
|
model SetLog {
|
||||||
@@ -114,6 +122,9 @@ model SetLog {
|
|||||||
|
|
||||||
@@index([workoutId])
|
@@index([workoutId])
|
||||||
@@index([exerciseId])
|
@@index([exerciseId])
|
||||||
|
// Sets are always rendered in setNumber order under a workout; this
|
||||||
|
// composite removes the sort.
|
||||||
|
@@index([workoutId, setNumber])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Program {
|
model Program {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
NAME_MAP,
|
||||||
|
parseCSV,
|
||||||
|
parseFloatMaybe,
|
||||||
|
parseIntMaybe,
|
||||||
|
getVariationNote,
|
||||||
|
resolveExerciseName,
|
||||||
|
parseDate,
|
||||||
|
} from '@/lib/csvParser';
|
||||||
|
|
||||||
|
describe('parseCSV', () => {
|
||||||
|
it('returns [] for empty input', () => {
|
||||||
|
expect(parseCSV('')).toEqual([]);
|
||||||
|
expect(parseCSV(' \n \n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a simple header + rows', () => {
|
||||||
|
const out = parseCSV('a,b,c\n1,2,3\n4,5,6\n');
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ a: '1', b: '2', c: '3' },
|
||||||
|
{ a: '4', b: '5', c: '6' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases header keys (case-insensitive lookup)', () => {
|
||||||
|
const out = parseCSV('Date,Exercise\n2026-01-01,Bench\n');
|
||||||
|
expect(out[0]).toEqual({ date: '2026-01-01', exercise: 'Bench' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quoted fields with commas', () => {
|
||||||
|
const out = parseCSV('a,b\n"hello, world","x"\n');
|
||||||
|
expect(out[0]).toEqual({ a: 'hello, world', b: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped double quotes inside quoted fields', () => {
|
||||||
|
const out = parseCSV('a\n"she said ""hi"""\n');
|
||||||
|
expect(out[0].a).toBe('she said "hi"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops cells with empty/whitespace values', () => {
|
||||||
|
const out = parseCSV('a,b,c\n1,,3\n');
|
||||||
|
expect(out[0]).toEqual({ a: '1', c: '3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes CRLF line endings', () => {
|
||||||
|
const out = parseCSV('a,b\r\n1,2\r\n');
|
||||||
|
expect(out).toEqual([{ a: '1', b: '2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores fully-empty lines', () => {
|
||||||
|
const out = parseCSV('a,b\n\n1,2\n\n3,4\n');
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ a: '1', b: '2' },
|
||||||
|
{ a: '3', b: '4' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFloatMaybe / parseIntMaybe', () => {
|
||||||
|
it('returns undefined for empty / undefined / non-numeric', () => {
|
||||||
|
expect(parseFloatMaybe(undefined)).toBeUndefined();
|
||||||
|
expect(parseFloatMaybe('')).toBeUndefined();
|
||||||
|
expect(parseFloatMaybe('not a number')).toBeUndefined();
|
||||||
|
expect(parseIntMaybe(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses valid numbers', () => {
|
||||||
|
expect(parseFloatMaybe('42.5')).toBe(42.5);
|
||||||
|
expect(parseIntMaybe('17')).toBe(17);
|
||||||
|
expect(parseIntMaybe('17.9')).toBe(17); // int truncates
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVariationNote', () => {
|
||||||
|
it('detects each known variation', () => {
|
||||||
|
expect(getVariationNote('Chinup (Narrow)')).toBe('narrow');
|
||||||
|
expect(getVariationNote('Chinup Negatives')).toBe('negatives');
|
||||||
|
expect(getVariationNote('Squat (Foot Elevated)')).toBe('foot elevated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown variations', () => {
|
||||||
|
expect(getVariationNote('Bench Press')).toBeNull();
|
||||||
|
expect(getVariationNote('Squat')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveExerciseName', () => {
|
||||||
|
it('maps every NAME_MAP entry to its canonical target', () => {
|
||||||
|
for (const [shorthand, canonical] of Object.entries(NAME_MAP)) {
|
||||||
|
expect(resolveExerciseName(shorthand)).toBe(canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through unknown names unchanged', () => {
|
||||||
|
expect(resolveExerciseName('Bench Press')).toBe('Bench Press');
|
||||||
|
expect(resolveExerciseName('Some Brand New Exercise')).toBe(
|
||||||
|
'Some Brand New Exercise',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the canonical examples from the route header comment', () => {
|
||||||
|
expect(resolveExerciseName('BB Row')).toBe('Barbell Row');
|
||||||
|
expect(resolveExerciseName('CoC')).toBe('Captains of Crush');
|
||||||
|
expect(resolveExerciseName('Hex DL')).toBe('Hex Bar Deadlift');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDate', () => {
|
||||||
|
it('parses M/D/YYYY (US-style) into noon-UTC ISO', () => {
|
||||||
|
expect(parseDate('1/27/2026')).toBe('2026-01-27T12:00:00.000Z');
|
||||||
|
// Single-digit month + day pad correctly.
|
||||||
|
expect(parseDate('3/5/2026')).toBe('2026-03-05T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses YYYY-MM-DD (ISO) into noon-UTC', () => {
|
||||||
|
expect(parseDate('2026-04-21')).toBe('2026-04-21T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces dates that fall on the same calendar day in US timezones', () => {
|
||||||
|
// Anchor at noon UTC means even US Pacific (UTC-8) sees the same day.
|
||||||
|
const out = parseDate('1/27/2026');
|
||||||
|
const d = new Date(out);
|
||||||
|
// 12:00 UTC = 04:00 PST = 05:00 PDT — still Jan 27 in Pacific.
|
||||||
|
expect(d.getUTCDate()).toBe(27);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||||
|
getCurrentUserMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', async (orig) => {
|
||||||
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
|
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||||
|
});
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { GET as getExercises, POST as postExercise } from '@/app/api/exercises/route';
|
||||||
|
import { POST as postWorkout, GET as getWorkouts } from '@/app/api/workouts/route';
|
||||||
|
|
||||||
|
// `NextRequest` accepts a slightly stricter RequestInit (no `signal:
|
||||||
|
// null`), so cast the standard RequestInit to the constructor's
|
||||||
|
// expected shape.
|
||||||
|
function jsonReq(url: string, body?: unknown, init?: RequestInit): NextRequest {
|
||||||
|
const opts: RequestInit = {
|
||||||
|
method: body !== undefined ? 'POST' : 'GET',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
return new NextRequest(url, opts as ConstructorParameters<typeof NextRequest>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeUser(opts: { email: string; isAdmin?: boolean }) {
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: opts.email,
|
||||||
|
passwordHash: 'fake',
|
||||||
|
isAdmin: opts.isAdmin ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/exercises', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await getExercises(jsonReq('http://x/api/exercises'));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only exercises owned by the requesting user', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
const bob = await makeUser({ email: 'b@x' });
|
||||||
|
await prisma.exercise.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: alice.id, name: 'Alice Squat', type: 'barbell', muscleGroups: '[]' },
|
||||||
|
{ userId: alice.id, name: 'Alice Bench', type: 'barbell', muscleGroups: '[]' },
|
||||||
|
{ userId: bob.id, name: 'Bob Curl', type: 'dumbbell', muscleGroups: '[]' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await getExercises(jsonReq('http://x/api/exercises'));
|
||||||
|
const body = await res.json();
|
||||||
|
const names = body.map((e: { name: string }) => e.name).sort();
|
||||||
|
expect(names).toEqual(['Alice Bench', 'Alice Squat']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by query string substring', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
await prisma.exercise.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: alice.id, name: 'Bench Press', type: 'barbell', muscleGroups: '[]' },
|
||||||
|
{ userId: alice.id, name: 'Cable Bench Fly', type: 'cable', muscleGroups: '[]' },
|
||||||
|
{ userId: alice.id, name: 'Squat', type: 'barbell', muscleGroups: '[]' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await getExercises(
|
||||||
|
jsonReq('http://x/api/exercises?q=Bench'),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.map((e: { name: string }) => e.name).sort()).toEqual([
|
||||||
|
'Bench Press',
|
||||||
|
'Cable Bench Fly',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/exercises', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await postExercise(
|
||||||
|
jsonReq('http://x/api/exercises', {
|
||||||
|
name: 'X',
|
||||||
|
type: 'barbell',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new exercise and stamps isCustom=true', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await postExercise(
|
||||||
|
jsonReq('http://x/api/exercises', {
|
||||||
|
name: 'My Custom Exercise',
|
||||||
|
type: 'machine',
|
||||||
|
muscleGroups: ['legs'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const ex = await prisma.exercise.findUnique({
|
||||||
|
where: { userId_name: { userId: alice.id, name: 'My Custom Exercise' } },
|
||||||
|
});
|
||||||
|
expect(ex).toBeTruthy();
|
||||||
|
expect(ex?.isCustom).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicates with 400', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: alice.id,
|
||||||
|
name: 'Duplicate',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await postExercise(
|
||||||
|
jsonReq('http://x/api/exercises', {
|
||||||
|
name: 'Duplicate',
|
||||||
|
type: 'barbell',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/already exists/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults inputFields based on type (cardio gets duration+calories)', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
await postExercise(
|
||||||
|
jsonReq('http://x/api/exercises', {
|
||||||
|
name: 'Treadmill Run',
|
||||||
|
type: 'cardio',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ex = await prisma.exercise.findUnique({
|
||||||
|
where: { userId_name: { userId: alice.id, name: 'Treadmill Run' } },
|
||||||
|
});
|
||||||
|
expect(JSON.parse(ex!.inputFields)).toEqual([
|
||||||
|
'sets',
|
||||||
|
'duration',
|
||||||
|
'calories',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 with details on validation failure', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await postExercise(
|
||||||
|
jsonReq('http://x/api/exercises', {
|
||||||
|
name: '',
|
||||||
|
type: 'barbell',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/validation/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/workouts', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await getWorkouts(
|
||||||
|
jsonReq('http://x/api/workouts'),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only workouts owned by the requesting user', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
const bob = await makeUser({ email: 'b@x' });
|
||||||
|
await prisma.workout.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: alice.id, date: new Date(), name: 'A1' },
|
||||||
|
{ userId: alice.id, date: new Date(), name: 'A2' },
|
||||||
|
{ userId: bob.id, date: new Date(), name: 'B1' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await getWorkouts(
|
||||||
|
jsonReq('http://x/api/workouts'),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.length).toBe(2);
|
||||||
|
expect(body.data.every((w: { name: string }) => w.name?.startsWith('A'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(body.meta.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits soft-deleted workouts (deletedAt set)', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
await prisma.workout.create({
|
||||||
|
data: { userId: alice.id, date: new Date(), name: 'visible' },
|
||||||
|
});
|
||||||
|
await prisma.workout.create({
|
||||||
|
data: {
|
||||||
|
userId: alice.id,
|
||||||
|
date: new Date(),
|
||||||
|
name: 'tombstoned',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await getWorkouts(
|
||||||
|
jsonReq('http://x/api/workouts'),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.map((w: { name: string }) => w.name)).toEqual(['visible']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/workouts', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await postWorkout(
|
||||||
|
jsonReq('http://x/api/workouts', { name: 'X' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an empty workout with just a date', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await postWorkout(
|
||||||
|
jsonReq('http://x/api/workouts', {
|
||||||
|
name: 'Today',
|
||||||
|
durationMinutes: 45,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const workout = await res.json();
|
||||||
|
expect(workout.name).toBe('Today');
|
||||||
|
expect(workout.durationMinutes).toBe(45);
|
||||||
|
expect(workout.userId).toBe(alice.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a workout with sets attached and persists set fields', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
const bench = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: alice.id,
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await postWorkout(
|
||||||
|
jsonReq('http://x/api/workouts', {
|
||||||
|
name: 'Push Day',
|
||||||
|
sets: [
|
||||||
|
{
|
||||||
|
exerciseId: bench.id,
|
||||||
|
setNumber: 1,
|
||||||
|
reps: 5,
|
||||||
|
weight: 225,
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
rpe: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseId: bench.id,
|
||||||
|
setNumber: 2,
|
||||||
|
reps: 5,
|
||||||
|
weight: 245,
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
rpe: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.setLogs).toHaveLength(2);
|
||||||
|
expect(body.setLogs[0].weight).toBe(225);
|
||||||
|
expect(body.setLogs[1].rpe).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative reps via Zod with 400', async () => {
|
||||||
|
const alice = await makeUser({ email: 'a@x' });
|
||||||
|
const bench = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: alice.id,
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const res = await postWorkout(
|
||||||
|
jsonReq('http://x/api/workouts', {
|
||||||
|
sets: [
|
||||||
|
{ exerciseId: bench.id, setNumber: 1, reps: -1, weightUnit: 'lbs' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/invalid/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,6 +123,20 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
|||||||
sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" >/dev/null
|
sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" >/dev/null
|
||||||
fi
|
fi
|
||||||
sqlite3 "$DB_PATH" "PRAGMA synchronous=NORMAL;" >/dev/null
|
sqlite3 "$DB_PATH" "PRAGMA synchronous=NORMAL;" >/dev/null
|
||||||
|
|
||||||
|
# Composite indexes for hot query paths. CREATE INDEX IF NOT EXISTS is
|
||||||
|
# idempotent — no-op if Prisma already created them via db push on a
|
||||||
|
# fresh install. These are also declared in schema.prisma so any future
|
||||||
|
# `prisma db push` keeps them in sync.
|
||||||
|
log "ensuring composite indexes are present"
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
CREATE INDEX IF NOT EXISTS Session_userId_expiresAt_idx
|
||||||
|
ON Session(userId, expiresAt);
|
||||||
|
CREATE INDEX IF NOT EXISTS Workout_userId_deletedAt_date_idx
|
||||||
|
ON Workout(userId, deletedAt, date);
|
||||||
|
CREATE INDEX IF NOT EXISTS SetLog_workoutId_setNumber_idx
|
||||||
|
ON SetLog(workoutId, setNumber);
|
||||||
|
" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sdk } from '../sdk'
|
import { sdk } from '../sdk'
|
||||||
import { changeAdminCredentials } from './changeAdminCredentials'
|
import { changeAdminCredentials } from './changeAdminCredentials'
|
||||||
import { toggleSignups } from './toggleSignups'
|
import { toggleSignups } from './toggleSignups'
|
||||||
|
import { verifyDatabase } from './verifyDatabase'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Package actions registered with StartOS.
|
* Package actions registered with StartOS.
|
||||||
@@ -11,7 +12,10 @@ import { toggleSignups } from './toggleSignups'
|
|||||||
* - toggle-signups: open/close the multi-user sign-up gate. The same
|
* - toggle-signups: open/close the multi-user sign-up gate. The same
|
||||||
* toggle is also available in-app at Settings -> Instance Settings
|
* toggle is also available in-app at Settings -> Instance Settings
|
||||||
* (admin only). See toggleSignups.ts.
|
* (admin only). See toggleSignups.ts.
|
||||||
|
* - verify-database: read-only PRAGMA integrity_check + quick_check
|
||||||
|
* plus row-count snapshot. Run after a backup or a host crash.
|
||||||
*/
|
*/
|
||||||
export const actions = sdk.Actions.of()
|
export const actions = sdk.Actions.of()
|
||||||
.addAction(changeAdminCredentials)
|
.addAction(changeAdminCredentials)
|
||||||
.addAction(toggleSignups)
|
.addAction(toggleSignups)
|
||||||
|
.addAction(verifyDatabase)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { sdk } from '../sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* verify-database — StartOS Package Action.
|
||||||
|
*
|
||||||
|
* Operator confidence check. Runs SQLite's `PRAGMA integrity_check` and
|
||||||
|
* a `PRAGMA quick_check` against /data/app.db, plus a few row-count
|
||||||
|
* sanity queries. Reports the results in the StartOS UI as a
|
||||||
|
* structured `result` so the operator can spot anomalies at a glance
|
||||||
|
* without dropping into a shell.
|
||||||
|
*
|
||||||
|
* Use cases:
|
||||||
|
* - "Did the StartOS Backup last night actually capture a healthy
|
||||||
|
* DB?" (Run the action AFTER a backup completes; integrity_check
|
||||||
|
* ok means the file is consistent.)
|
||||||
|
* - "I had a power loss / the host crashed — is the DB ok?"
|
||||||
|
* - "I just sideloaded a fresh image — did the seed copy correctly?"
|
||||||
|
*
|
||||||
|
* Read-only. Safe to run while the service is running. We use
|
||||||
|
* allowedStatuses: 'only-running' so the SQL goes through the live
|
||||||
|
* service's data-mounted subcontainer rather than starting one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const verifyDatabase = sdk.Action.withoutInput(
|
||||||
|
'verify-database',
|
||||||
|
async () => ({
|
||||||
|
name: 'Verify database integrity',
|
||||||
|
description:
|
||||||
|
"Read-only. Runs SQLite PRAGMA integrity_check + quick_check against /data/app.db and reports row counts. Useful after a backup, a host crash, or a fresh sideload.",
|
||||||
|
warning: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
}),
|
||||||
|
async ({ effects }) => {
|
||||||
|
let result = { integrity: 'unknown', quickCheck: 'unknown', counts: {} as Record<string, string> }
|
||||||
|
|
||||||
|
await sdk.SubContainer.withTemp(
|
||||||
|
effects,
|
||||||
|
{ imageId: 'main' },
|
||||||
|
sdk.Mounts.of().mountVolume({
|
||||||
|
volumeId: 'main',
|
||||||
|
subpath: null,
|
||||||
|
mountpoint: '/data',
|
||||||
|
readonly: true,
|
||||||
|
}),
|
||||||
|
'verify-database',
|
||||||
|
async (sc) => {
|
||||||
|
const sql = [
|
||||||
|
"SELECT 'integrity:' || (SELECT * FROM pragma_integrity_check LIMIT 1);",
|
||||||
|
"SELECT 'quick:' || (SELECT * FROM pragma_quick_check LIMIT 1);",
|
||||||
|
"SELECT 'count:' || 'User' || '=' || (SELECT COUNT(*) FROM User);",
|
||||||
|
"SELECT 'count:' || 'Workout' || '=' || (SELECT COUNT(*) FROM Workout WHERE deletedAt IS NULL);",
|
||||||
|
"SELECT 'count:' || 'Exercise' || '=' || (SELECT COUNT(*) FROM Exercise);",
|
||||||
|
"SELECT 'count:' || 'SetLog' || '=' || (SELECT COUNT(*) FROM SetLog);",
|
||||||
|
"SELECT 'count:' || 'Session' || '=' || (SELECT COUNT(*) FROM Session WHERE expiresAt > CURRENT_TIMESTAMP);",
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const res = await sc.execFail(['sqlite3', '/data/app.db'], { input: sql }, 60_000)
|
||||||
|
const lines = res.stdout
|
||||||
|
.toString()
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('integrity:')) {
|
||||||
|
result.integrity = line.slice('integrity:'.length)
|
||||||
|
} else if (line.startsWith('quick:')) {
|
||||||
|
result.quickCheck = line.slice('quick:'.length)
|
||||||
|
} else if (line.startsWith('count:')) {
|
||||||
|
const [name, value] = line.slice('count:'.length).split('=')
|
||||||
|
if (name && value !== undefined) {
|
||||||
|
result.counts[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = result.integrity === 'ok' && result.quickCheck === 'ok'
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: '1',
|
||||||
|
title: ok ? 'Database is healthy' : 'Database integrity issues detected',
|
||||||
|
message: ok
|
||||||
|
? 'integrity_check + quick_check both returned ok. Row counts below.'
|
||||||
|
: `integrity_check returned: ${result.integrity}; quick_check returned: ${result.quickCheck}. Investigate before relying on the next backup.`,
|
||||||
|
result: {
|
||||||
|
type: 'group',
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
type: 'single',
|
||||||
|
name: 'integrity_check',
|
||||||
|
description: 'PRAGMA integrity_check result. Should be "ok".',
|
||||||
|
value: result.integrity,
|
||||||
|
copyable: false,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'single',
|
||||||
|
name: 'quick_check',
|
||||||
|
description: 'PRAGMA quick_check result. Should be "ok".',
|
||||||
|
value: result.quickCheck,
|
||||||
|
copyable: false,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
...Object.entries(result.counts).map(([name, value]) => ({
|
||||||
|
type: 'single' as const,
|
||||||
|
name: `${name} rows`,
|
||||||
|
description: name === 'Session' ? 'Active (unexpired) sessions.' : null,
|
||||||
|
value,
|
||||||
|
copyable: false,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user