Files
proof-of-work/proof-of-work/app/api/me/import/route.ts
T
Keysat 990f5582b8 Typed Prisma queries, bcrypt native, CSP nonces, /api/me/import, more tests
Typed Prisma queries
- where: any in app/api/workouts/route.ts (GET + POST) and
  lib/db/workouts.ts replaced with Prisma.WorkoutWhereInput +
  Prisma.WorkoutCreateInput + Prisma.DateTimeFilter. Catches typos
  at compile time and surfaces query shape directly in tooltips.

Workout import endpoint tests (tests/routes-import.test.ts)
- 7 tests covering /api/workouts/import/save: 401 unauthenticated,
  empty workouts rejected, case-insensitive name matching against
  existing exercises, new-exercise creation with isCustom=true and
  type='other' default, explicit existingExerciseId honored over
  name lookup, multiple workouts per call, sequential setNumber
  per exercise per workout.

bcryptjs -> bcrypt (native)
- Roughly 10x faster than the pure-JS implementation under load —
  login latency drops from ~250ms to ~25ms. Hash format is fully
  cross-compatible with bcryptjs ($2a$ / $2b$ both verify), so
  existing user passwords keep working without migration.
- Dockerfile builder stage adds python3 + make + g++ as a safety net
  for native node-gyp compilation on alpine when prebuilt binaries
  aren't available.
- Runner stage explicitly COPYs node_modules/bcrypt so the .node
  binding is unambiguously present even if Next.js standalone
  tracing somehow misses it.
- StartOS package's changeAdminCredentials.ts keeps bcryptjs (it's
  bundled by ncc into a single JS file and runs only on the rare
  admin action; native bcrypt would require shipping the .node
  binding through ncc which it doesn't handle gracefully).

CSP nonces (middleware.ts + next.config.js)
- Per-request nonce generated in middleware. Forwarded to Next via
  the x-nonce request header, which Next 13.4+ automatically stamps
  onto its inline bootstrap scripts. CSP response header includes
  `'nonce-${nonce}' 'strict-dynamic'`, dropping the previous
  `'unsafe-inline'` from script-src.
- Static CSP removed from next.config.js (middleware-set headers
  override static ones, so keeping both was redundant).
- Middleware matcher widened to all paths except static assets so
  the CSP applies to every page response. Existing /main + /api
  auth gating preserved.
- style-src keeps 'unsafe-inline' — Next/Tailwind still inject
  critical inline <style>; tightening that requires hash-based
  style-src or per-style nonce stamping (Next doesn't auto-do
  either). Worth a follow-up if you want the cleanest possible CSP.

/api/me/import (mirror of /api/me/export)
- Accepts the same JSON shape /api/me/export emits (schema string
  validated: only `proof-of-work-export@1` accepted today).
- mode: 'merge' (default) — adds imported rows; existing exercises
  with matching names are NOT overwritten (the user's custom version
  wins). All workout sets with a known exercise get rebound to the
  user's actual exercise id via name lookup.
- mode: 'replace' — wipes the user's exercises/workouts/sets first,
  then imports. Requires `confirm: "REPLACE"` in the body.
- Always scoped to the actor — never touches other users' data.
- Profile/admin flag/sessions/InstanceSettings deliberately not
  imported (account identity stays put).
- 7 tests cover: 401, schema rejection, merge create+skip, replace
  confirmation gate, replace wipes-then-imports, isolation across
  users.
- ExportMyData component grew Import (merge) + Import (replace)
  buttons with native browser confirm() before the destructive
  replace.

Test suite now 81 tests across 9 files in ~2.6s.
2026-05-09 11:05:03 -05:00

241 lines
8.6 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* POST /api/me/import
*
* Restore a previously-exported account dump (from /api/me/export) into
* the CURRENT user's account. Use cases:
* - Move from one Proof of Work instance to another (export there,
* import here).
* - Disaster recovery from a known-good export file when the live
* /data is gone.
*
* Behavior:
* - Mode 'merge' (default): existing data stays put. Imported
* exercises with names that already exist for this user are
* skipped (no overwrite); workouts and sets are always added as
* new rows with fresh IDs.
* - Mode 'replace': hard-deletes everything the current user owns
* (workouts, sets, exercises, programs, preferences) before
* inserting the imported data. Profile + admin flag are
* preserved. Requires `confirm: "REPLACE"` in the body.
*
* What is NOT imported:
* - User identity (email, password, isAdmin) — the rows always go to
* the actor's id.
* - Sessions — never round-tripped; an import shouldn't grant new
* login tokens.
* - InstanceSettings — instance-wide, not account-owned.
*
* The export schema string is checked: only `proof-of-work-export@1`
* is accepted today. Future schema bumps must add migration code here.
*/
const exerciseImport = z.object({
name: z.string().min(1),
description: z.string().nullable().optional(),
type: z.string(),
muscleGroups: z.string(), // already JSON-stringified in export
inputFields: z.string().optional(),
defaultWeightUnit: z.string().nullable().optional(),
isCustom: z.boolean().optional(),
});
const setLogImport = z.object({
setNumber: z.number().int().positive(),
reps: z.number().int().nullable().optional(),
weight: z.number().nullable().optional(),
weightUnit: z.string().optional(),
rpe: z.number().int().nullable().optional(),
durationSeconds: z.number().int().nullable().optional(),
distance: z.number().nullable().optional(),
distanceUnit: z.string().nullable().optional(),
calories: z.number().int().nullable().optional(),
customMetrics: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// The exported set carries an exerciseId pointing into the export's
// own exercise list, but NOT a stable id we can FK on after import.
// We re-resolve via the parent workout's exercise-name lookup below.
exerciseId: z.string(),
});
const workoutImport = z.object({
date: z.string(), // ISO
name: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
durationMinutes: z.number().int().nullable().optional(),
difficulty: z.number().int().nullable().optional(),
caloriesBurned: z.number().int().nullable().optional(),
setLogs: z.array(setLogImport),
});
const importPayload = z.object({
schema: z.literal('proof-of-work-export@1'),
exercises: z.array(exerciseImport),
workouts: z.array(workoutImport),
});
const requestBody = z.object({
payload: importPayload,
mode: z.enum(['merge', 'replace']).default('merge'),
confirm: z.string().optional(),
});
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const parsed = requestBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid import payload', details: parsed.error.errors },
{ status: 400 },
);
}
const { payload, mode, confirm } = parsed.data;
if (mode === 'replace' && confirm !== 'REPLACE') {
return NextResponse.json(
{
error:
'Replace mode requires `confirm: "REPLACE"` in the request body. This deletes every workout/exercise/program owned by your account before importing.',
},
{ status: 400 },
);
}
// Build an export-id -> exercise-name map so set logs can be
// rebound to the freshly-created exercises after merge/replace.
// The export's `id` field is on each row but Zod's exercise schema
// doesn't include id (we don't need it as a column to insert).
// Read it from the raw payload to preserve the FK linkage between
// the export's setLogs.exerciseId and its exercises[].id.
const exportExerciseIdToName = new Map<string, string>();
const rawExercises = (body?.payload?.exercises ?? []) as Array<
Record<string, unknown>
>;
for (const ex of rawExercises) {
if (typeof ex?.id === 'string' && typeof ex?.name === 'string') {
exportExerciseIdToName.set(ex.id, ex.name);
}
}
const summary = await prisma.$transaction(
async (tx) => {
if (mode === 'replace') {
// Order matters because of FK cascades — explicit deletes
// are still cleanest to avoid relying on cascade semantics.
await tx.setLog.deleteMany({
where: { workout: { userId: user.id } },
});
await tx.workout.deleteMany({ where: { userId: user.id } });
await tx.exercise.deleteMany({ where: { userId: user.id } });
}
// ---- Exercises: upsert by (userId, name) -------------------
const nameToNewId = new Map<string, string>();
let exercisesCreated = 0;
let exercisesSkipped = 0;
for (const ex of payload.exercises) {
const existing = await tx.exercise.findUnique({
where: { userId_name: { userId: user.id, name: ex.name } },
select: { id: true },
});
if (existing) {
nameToNewId.set(ex.name, existing.id);
exercisesSkipped++;
continue;
}
const created = await tx.exercise.create({
data: {
userId: user.id,
name: ex.name,
description: ex.description ?? null,
type: ex.type,
muscleGroups: ex.muscleGroups,
inputFields: ex.inputFields ?? '["sets","reps","weight"]',
defaultWeightUnit: ex.defaultWeightUnit ?? null,
isCustom: ex.isCustom ?? false,
} as Prisma.ExerciseUncheckedCreateInput,
select: { id: true, name: true },
});
nameToNewId.set(created.name, created.id);
exercisesCreated++;
}
// ---- Workouts + sets ---------------------------------------
let workoutsCreated = 0;
let setsCreated = 0;
let setsSkipped = 0;
for (const w of payload.workouts) {
const setData = [];
for (const s of w.setLogs) {
const exerciseName = exportExerciseIdToName.get(s.exerciseId);
const targetId = exerciseName
? nameToNewId.get(exerciseName)
: undefined;
if (!targetId) {
setsSkipped++;
continue;
}
setData.push({
exerciseId: targetId,
setNumber: s.setNumber,
reps: s.reps ?? null,
weight: s.weight ?? null,
weightUnit: s.weightUnit ?? 'lbs',
rpe: s.rpe ?? null,
durationSeconds: s.durationSeconds ?? null,
distance: s.distance ?? null,
distanceUnit: s.distanceUnit ?? null,
calories: s.calories ?? null,
customMetrics: s.customMetrics ?? null,
notes: s.notes ?? null,
});
}
await tx.workout.create({
data: {
userId: user.id,
date: new Date(w.date),
name: w.name ?? null,
notes: w.notes ?? null,
durationMinutes: w.durationMinutes ?? null,
difficulty: w.difficulty ?? null,
caloriesBurned: w.caloriesBurned ?? null,
setLogs: setData.length > 0 ? { create: setData } : undefined,
} as Prisma.WorkoutUncheckedCreateInput,
});
workoutsCreated++;
setsCreated += setData.length;
}
return {
mode,
exercisesCreated,
exercisesSkipped,
workoutsCreated,
setsCreated,
setsSkipped,
};
},
{ timeout: 60_000 },
);
return NextResponse.json(summary);
} catch (err) {
console.error('POST /api/me/import error:', err);
return NextResponse.json(
{ error: (err as Error).message ?? 'Internal server error' },
{ status: 500 },
);
}
}