Files
proof-of-work/proof-of-work/app/api/me/export/route.ts
T
Keysat 5de974edaf ESLint, server-action tests, export-my-data, enriched healthcheck, CHANGELOG
ESLint
- Pinned eslint@^8 + eslint-config-next@^14 to match Next 14's `next lint`.
  ESLint 9's flat-config breaks `next lint` for legacy projects.
- .eslintrc.json extends next/core-web-vitals; ignores tests/, scripts/,
  prisma/data/, .next/, node_modules.
- 7 pre-existing warnings surfaced (exhaustive-deps + alt-text + img tag
  in user-written components). Left as warnings — pre-existing, not
  breaking. CI runs lint; warnings don't fail the job.

Server action tests (tests/actions-admin.test.ts, tests/actions-auth.test.ts)
- Vitest setup file (tests/helpers/setup-actions.ts) sets DATABASE_URL
  to a per-process temp SQLite DB and runs `prisma db push` BEFORE
  lib/prisma instantiates its global PrismaClient. Tests then call the
  real server actions against an isolated DB.
- vi.mock + vi.hoisted to mock @/lib/auth.getCurrentUser, next/headers
  cookies+headers, next/navigation redirect, next/cache revalidatePath.
- Coverage:
  - admin: setUserAdmin (Forbidden, promote, last-admin demote refused,
    demote-with-other-admin allowed), deleteUser (last-admin guard,
    self-delete refused, cascading delete to exercises + workouts),
    adminResetPassword (hash-and-revoke, short-password rejected).
  - auth flows: signupAction (closed by default, opens-and-creates,
    mismatched confirm rejected, short pwd rejected, malformed email
    rejected, no email-enumeration leak), changePasswordAction
    (rotate-and-revoke-others, wrong current pwd rejected, no-op pwd
    rejected), deleteMyAccountAction (phrase required, password required,
    last-admin refused, success cascades + clears cookie + redirects).
- Total suite: 34 tests, ~2s.

Export my data (/api/me/export + Settings -> Export my data)
- Downloads a JSON dump of every workout/set/exercise/program tied to
  the user. Excludes password hash and sessions. Filename includes
  email + date. content-disposition: attachment, no-store cache.
- Exported shape matches the underlying tables 1:1 so a future "import
  my data" flow can round-trip without ambiguity.

Enriched /api/health
- Now reports: database.connected, database.journalMode (and walEnabled
  shortcut), users count, instanceSettings.signupsOpen, library.available
  + sizeBytes. Surfaces a `warnings` array if journal_mode != 'wal' but
  doesn't fail the check (app still works without WAL — just unsafe for
  online backups). Returns 503 only on hard DB failure.

CHANGELOG.md
- Single Unreleased section documenting everything that will ship as
  v1.0.0:1 once the maintainer drops a fresh /data snapshot. Added /
  Changed / Removed / Compat-notes sections.
2026-05-09 10:41:13 -05:00

90 lines
2.4 KiB
TypeScript

import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* GET /api/me/export
*
* Returns a JSON document containing every row this user owns:
* profile, preferences, custom exercises, workouts (with set logs),
* programs (and the nested weeks/days/exercises). The download omits
* the user's password hash and session tokens.
*
* The shape is intentionally a 1:1 dump of the underlying tables so
* a future "import my data into a different instance" feature can
* round-trip without ambiguity.
*/
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const [profile, exercises, workouts, programs] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
email: true,
name: true,
isAdmin: true,
createdAt: true,
lastLoginAt: true,
userPreferences: true,
},
}),
prisma.exercise.findMany({
where: { userId: user.id },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
}),
prisma.workout.findMany({
where: { userId: user.id, deletedAt: null },
include: {
setLogs: {
orderBy: { setNumber: 'asc' },
},
},
orderBy: { date: 'desc' },
}),
prisma.program.findMany({
where: { userId: user.id },
include: {
weeks: {
include: {
days: {
include: { exercises: true },
},
},
},
},
}),
]);
const payload = {
schema: 'proof-of-work-export@1',
exportedAt: new Date().toISOString(),
profile,
exercises,
workouts,
programs,
counts: {
exercises: exercises.length,
workouts: workouts.length,
sets: workouts.reduce((sum, w) => sum + w.setLogs.length, 0),
programs: programs.length,
},
};
const date = new Date().toISOString().slice(0, 10);
const filename = `proof-of-work-${user.email.replace(/[^a-z0-9]/gi, '_')}-${date}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="${filename}"`,
'cache-control': 'no-store',
},
});
}