990f5582b8
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.
241 lines
8.6 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|