Connect the React Native app to the admin backend so users and scans flow
into the panel and bans take effect on the device.
- Activate the bearer() plugin in lib/auth.ts so mobile clients can pass
the better-auth session token via Authorization: Bearer header
- Add requireMobileAuth() helper in lib/auth-guard.ts that resolves the
session, re-fetches the user from DB (banned flag is on User, not in
the Session payload) and returns 403 with banned/bannedReason for
banned accounts
- Extend CORS in middleware.ts to allow POST + Authorization header on
/api/mobile/* (preflight was failing before)
- New routes:
POST /api/mobile/auth/sync — passwordless mobile auth via
deterministic password derived from sha256(email + deviceId + pepper).
Tries signIn first, falls back to signUp on USER_NOT_FOUND. Returns
409 when the email exists with a different deviceId.
GET /api/mobile/auth/me — current user enriched with
banned/bannedReason/role/xp/level
POST /api/mobile/auth/sign-out — best-effort session revocation
POST /api/mobile/scans — create a scan, resolves diseaseSlug
to diseaseId, never accepts an imageUrl from the device (V1 keeps
photos local-only)
GET /api/mobile/scans — own scans, 50 most recent
Validated end-to-end via curl: signUp → me → repeat sync (idempotent) →
post scan → ban via DB → me reflects banned: true → POST scans returns
403 + banned/bannedReason.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
2.8 KiB
TypeScript
108 lines
2.8 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { requireMobileAuth } from "@/lib/auth-guard";
|
|
import { mobileScanCreateSchema } from "@/lib/validations";
|
|
|
|
const CORS_HEADERS = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
"X-API-Version": "1.0",
|
|
};
|
|
|
|
export async function OPTIONS() {
|
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const guard = await requireMobileAuth(request);
|
|
if ("error" in guard) {
|
|
return Response.json(
|
|
"banned" in guard
|
|
? { error: guard.error, banned: true, bannedReason: guard.bannedReason }
|
|
: { error: guard.error },
|
|
{ status: guard.status, headers: CORS_HEADERS },
|
|
);
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return Response.json(
|
|
{ error: "Invalid JSON" },
|
|
{ status: 400, headers: CORS_HEADERS },
|
|
);
|
|
}
|
|
|
|
const parsed = mobileScanCreateSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return Response.json(
|
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
|
{ status: 400, headers: CORS_HEADERS },
|
|
);
|
|
}
|
|
|
|
const { confidence, diseaseSlug, latitude, longitude, deviceId } =
|
|
parsed.data;
|
|
|
|
let diseaseId: string | null = null;
|
|
if (diseaseSlug) {
|
|
const disease = await prisma.disease.findUnique({
|
|
where: { slug: diseaseSlug },
|
|
select: { id: true },
|
|
});
|
|
diseaseId = disease?.id ?? null;
|
|
}
|
|
|
|
const scan = await prisma.scan.create({
|
|
data: {
|
|
userId: guard.user.id,
|
|
confidence,
|
|
diseaseId,
|
|
latitude: latitude ?? null,
|
|
longitude: longitude ?? null,
|
|
deviceId: deviceId ?? null,
|
|
imageUrl: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
confidence: true,
|
|
latitude: true,
|
|
longitude: true,
|
|
createdAt: true,
|
|
disease: { select: { slug: true, name: true } },
|
|
},
|
|
});
|
|
|
|
return Response.json({ scan }, { status: 201, headers: CORS_HEADERS });
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const guard = await requireMobileAuth(request);
|
|
if ("error" in guard) {
|
|
return Response.json(
|
|
"banned" in guard
|
|
? { error: guard.error, banned: true, bannedReason: guard.bannedReason }
|
|
: { error: guard.error },
|
|
{ status: guard.status, headers: CORS_HEADERS },
|
|
);
|
|
}
|
|
|
|
const scans = await prisma.scan.findMany({
|
|
where: { userId: guard.user.id },
|
|
select: {
|
|
id: true,
|
|
confidence: true,
|
|
latitude: true,
|
|
longitude: true,
|
|
createdAt: true,
|
|
disease: { select: { slug: true, name: true } },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: 50,
|
|
});
|
|
|
|
return Response.json({ scans }, { headers: CORS_HEADERS });
|
|
}
|