diff --git a/proof-of-work/app/api/import/parse/route.ts b/proof-of-work/app/api/import/parse/route.ts index 212ba97..bf8316b 100644 --- a/proof-of-work/app/api/import/parse/route.ts +++ b/proof-of-work/app/api/import/parse/route.ts @@ -1,37 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; - -// Exercise name mapping - CSV shorthand to DB names -const NAME_MAP: Record = { - "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", -}; +import { + parseCSV, + parseFloatMaybe, + parseIntMaybe, + getVariationNote, + resolveExerciseName, + parseDate, +} from "@/lib/csvParser"; interface ParsedSet { setNumber: number; @@ -65,121 +42,6 @@ interface ParseResponse { unmapped: string[]; } -function parseCSV(content: string): Array> { - 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> = []; - - for (let i = 1; i < parsedRows.length; i++) { - const values = parsedRows[i]; - const out: Record = {}; - 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) { try { const user = await getCurrentUser(); diff --git a/proof-of-work/app/main/settings/page.tsx b/proof-of-work/app/main/settings/page.tsx index 51b33f5..1fc224c 100644 --- a/proof-of-work/app/main/settings/page.tsx +++ b/proof-of-work/app/main/settings/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { getCurrentUser } from "@/lib/auth"; import SettingsForm from "@/components/settings/SettingsForm"; import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; +import SessionsList from "@/components/settings/SessionsList"; import ExportMyData from "@/components/settings/ExportMyData"; import DangerZone from "@/components/settings/DangerZone"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; @@ -30,6 +31,7 @@ export default async function SettingsPage() {
+ {user.isAdmin && instanceSettings && ( { + 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 }; +} diff --git a/proof-of-work/components/settings/SessionsList.tsx b/proof-of-work/components/settings/SessionsList.tsx new file mode 100644 index 0000000..39e70f1 --- /dev/null +++ b/proof-of-work/components/settings/SessionsList.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+
+

+ Active sessions +

+

+ Each row is a browser or device with a valid session cookie. + Revoking ends that session immediately. +

+
+ {otherCount > 0 && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {rows == null ? ( +

Loading...

+ ) : rows.length === 0 ? ( +

No active sessions.

+ ) : ( +
    + {rows.map((s) => ( +
  • +
    +
    + ...{s.tokenSuffix} + {s.isCurrent && ( + + this device + + )} +
    +
    + Started {relAge(s.createdAt)} · Expires{' '} + {new Date(s.expiresAt).toLocaleDateString()} +
    +
    + {!s.isCurrent && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/proof-of-work/lib/csvParser.ts b/proof-of-work/lib/csvParser.ts new file mode 100644 index 0000000..c9fbaa1 --- /dev/null +++ b/proof-of-work/lib/csvParser.ts @@ -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 = { + '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> { + 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> = []; + + for (let i = 1; i < parsedRows.length; i++) { + const values = parsedRows[i]; + const out: Record = {}; + 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(); +} diff --git a/proof-of-work/prisma/schema.prisma b/proof-of-work/prisma/schema.prisma index 62954ff..997ace8 100644 --- a/proof-of-work/prisma/schema.prisma +++ b/proof-of-work/prisma/schema.prisma @@ -45,6 +45,10 @@ model Session { @@index([userId]) @@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 { @@ -89,6 +93,10 @@ model Workout { @@index([userId]) @@index([date]) @@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 { @@ -114,6 +122,9 @@ model SetLog { @@index([workoutId]) @@index([exerciseId]) + // Sets are always rendered in setNumber order under a workout; this + // composite removes the sort. + @@index([workoutId, setNumber]) } model Program { diff --git a/proof-of-work/tests/csvParser.test.ts b/proof-of-work/tests/csvParser.test.ts new file mode 100644 index 0000000..d92629a --- /dev/null +++ b/proof-of-work/tests/csvParser.test.ts @@ -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); + }); +}); diff --git a/proof-of-work/tests/routes-crud.test.ts b/proof-of-work/tests/routes-crud.test.ts new file mode 100644 index 0000000..6117563 --- /dev/null +++ b/proof-of-work/tests/routes-crud.test.ts @@ -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; + 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[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); + }); +}); diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh index 1ca2ec1..e82a2ec 100755 --- a/start9/0.4/docker_entrypoint.sh +++ b/start9/0.4/docker_entrypoint.sh @@ -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 fi 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 # ----------------------------------------------------------------------------- diff --git a/start9/0.4/startos/actions/index.ts b/start9/0.4/startos/actions/index.ts index 9c27fa4..27888ff 100644 --- a/start9/0.4/startos/actions/index.ts +++ b/start9/0.4/startos/actions/index.ts @@ -1,6 +1,7 @@ import { sdk } from '../sdk' import { changeAdminCredentials } from './changeAdminCredentials' import { toggleSignups } from './toggleSignups' +import { verifyDatabase } from './verifyDatabase' /** * 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 is also available in-app at Settings -> Instance Settings * (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() .addAction(changeAdminCredentials) .addAction(toggleSignups) + .addAction(verifyDatabase) diff --git a/start9/0.4/startos/actions/verifyDatabase.ts b/start9/0.4/startos/actions/verifyDatabase.ts new file mode 100644 index 0000000..f12d1fa --- /dev/null +++ b/start9/0.4/startos/actions/verifyDatabase.ts @@ -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 } + + 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, + })), + ], + }, + } + }, +)