Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
422 changes: 422 additions & 0 deletions SPE-M-README.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";

export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

return NextResponse.json({ user: session.user });
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
96 changes: 96 additions & 0 deletions app/api/forms/[id]/finalize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { auth } from "@/lib/auth";
import { db } from "@/db/drizzle";
import { forms, formCriteria, auditLogs } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { calculateTotalScore, classifyProfile } from "@/lib/spe-m-criteria";

// POST /api/forms/[id]/finalize - Finalize form (lock for editing)
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;

// Check if form exists and belongs to user
const existingForm = await db
.select()
.from(forms)
.where(and(eq(forms.id, id), eq(forms.userId, session.user.id)))
.limit(1);

if (existingForm.length === 0) {
return NextResponse.json({ error: "Form not found" }, { status: 404 });
}

Comment on lines +26 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

TOCTOU race: finalize check vs update.

Make update conditional on status != 'finalized' and verify affected rows.

+import { eq, and, ne } from "drizzle-orm";
...
-    await db
-      .update(forms)
-      .set({
+    const res = await db
+      .update(forms)
+      .set({
         status: "finalized",
         totalScore: totalScore.toString(),
         profileClassification: profile.classification,
         finalizedAt: new Date(),
         updatedAt: new Date(),
-      })
-      .where(eq(forms.id, id));
+      })
+      .where(and(eq(forms.id, id), ne(forms.status, "finalized")));
+    if ((res as any)?.rowCount === 0) {
+      return NextResponse.json({ error: "Form is already finalized" }, { status: 409 });
+    }

Also applies to: 59-70

🤖 Prompt for AI Agents
In app/api/forms/[id]/finalize/route.ts around lines 26-36 (and also apply same
fix at lines 59-70), replace the separate existence check + unconditional update
with an atomic conditional update that includes a WHERE status != 'finalized'
(or equivalent) so the DB only changes rows that are not already finalized;
after executing the update inspect the affected row count and if zero return a
409/appropriate error indicating the form is already finalized (or 404 if no
such form for the user), otherwise proceed normally — this eliminates the TOCTOU
race by making the finalize action conditional and verifying affected rows.

if (existingForm[0].status === "finalized") {
return NextResponse.json(
{ error: "Form is already finalized" },
{ status: 400 }
);
}

// Get all criteria to recalculate score
const criteria = await db
.select()
.from(formCriteria)
.where(eq(formCriteria.formId, id));

// Calculate final score
const criteriaData = criteria.map((c) => ({
criterionNumber: c.criterionNumber,
data: (c.data as Record<string, string>) || {},
}));

const totalScore = calculateTotalScore(criteriaData);
const profile = classifyProfile(totalScore);

// Update form to finalized status
await db
.update(forms)
.set({
status: "finalized",
totalScore: totalScore.toString(),
profileClassification: profile.classification,
finalizedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(forms.id, id));

// Create audit log
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: "update",
entityType: "form",
entityId: id,
ipAddress: request.ip || null,
userAgent: request.headers.get("user-agent") || null,
metadata: { action: "finalized", totalScore, classification: profile.classification },
timestamp: new Date(),
});

return NextResponse.json({
message: "Form finalized successfully",
totalScore,
profileClassification: profile,
});
Comment on lines +84 to +88
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Response shape inconsistent with other endpoints.

Return the classification string under profileClassification; include full profile separately if needed.

-    return NextResponse.json({
-      message: "Form finalized successfully",
-      totalScore,
-      profileClassification: profile,
-    });
+    return NextResponse.json({
+      message: "Form finalized successfully",
+      totalScore,
+      profileClassification: profile.classification,
+      profile,
+    });
🤖 Prompt for AI Agents
In app/api/forms/[id]/finalize/route.ts around lines 84 to 88, the response
currently returns the full profile object under profileClassification which
breaks the response shape; change the response to set profileClassification to
the classification string (e.g., profile.classification or
profile?.classification) and, if the full profile must be returned, include it
under a separate key (e.g., profile or fullProfile) so other endpoints receive
the expected simple string in profileClassification.

} catch (error) {
console.error("Error finalizing form:", error);
return NextResponse.json(
{ error: "Failed to finalize form" },
{ status: 500 }
);
}
}
206 changes: 206 additions & 0 deletions app/api/forms/[id]/images/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { auth } from "@/lib/auth";
import { db } from "@/db/drizzle";
import { forms, formImages, auditLogs } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { uploadImage } from "@/lib/upload-image";

// POST /api/forms/[id]/images - Upload image for form
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;

// Check if form exists and belongs to user
const existingForm = await db
.select()
.from(forms)
.where(and(eq(forms.id, id), eq(forms.userId, session.user.id)))
.limit(1);

if (existingForm.length === 0) {
return NextResponse.json({ error: "Form not found" }, { status: 404 });
}

const formData = await request.formData();
const file = formData.get("file") as File;
const imageType = formData.get("imageType") as string;

if (!file || !imageType) {
return NextResponse.json(
{ error: "File and image type are required" },
{ status: 400 }
);
}

// Validate image type
const validImageTypes = [
"frontal",
"profile_right",
"profile_left",
"oblique_right",
"oblique_left",
"base",
];

if (!validImageTypes.includes(imageType)) {
return NextResponse.json(
{ error: "Invalid image type" },
{ status: 400 }
);
}

// Check if image of this type already exists for this form
const existingImage = await db
.select()
.from(formImages)
.where(
and(eq(formImages.formId, id), eq(formImages.imageType, imageType))
)
.limit(1);

// Upload to R2 storage
const imageUrl = await uploadImage(file, `spe-m/${id}/${imageType}`);

const imageId = nanoid();
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When updating an existing image we still generate a new identifier, so the audit log’s entityId no longer matches the stored record. Reuse the existing image id so updates are audited against the correct entity.

Prompt for AI agents
Address the following comment on app/api/forms/[id]/images/route.ts at line 77:

<comment>When updating an existing image we still generate a new identifier, so the audit log’s entityId no longer matches the stored record. Reuse the existing image id so updates are audited against the correct entity.</comment>

<file context>
@@ -0,0 +1,206 @@
+    // Upload to R2 storage
+    const imageUrl = await uploadImage(file, `spe-m/${id}/${imageType}`);
+
+    const imageId = nanoid();
+    const imageData = {
+      id: imageId,
</file context>
Suggested change
const imageId = nanoid();
const imageId = existingImage.length > 0 ? existingImage[0].id : nanoid();
Fix with Cubic

const imageData = {
id: imageId,
formId: id,
imageType,
storageUrl: imageUrl,
thumbnailUrl: null, // TODO: Generate thumbnail
annotations: null,
metadata: {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
},
uploadedAt: new Date(),
updatedAt: new Date(),
};

// If image exists, update it; otherwise insert
if (existingImage.length > 0) {
await db
.update(formImages)
.set({
storageUrl: imageUrl,
metadata: imageData.metadata,
updatedAt: new Date(),
})
.where(eq(formImages.id, existingImage[0].id));
} else {
await db.insert(formImages).values(imageData);
}

// Create audit log
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: "create",
entityType: "image",
entityId: imageId,
ipAddress: request.ip || null,
userAgent: request.headers.get("user-agent") || null,
metadata: { formId: id, imageType },
timestamp: new Date(),
});

return NextResponse.json(
{ image: existingImage.length > 0 ? existingImage[0] : imageData },
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response for an updated image returns the pre-update snapshot from existingImage[0], so clients receive stale storageUrl and metadata despite the database having new values. Build the response from the fresh data you just persisted.

Prompt for AI agents
Address the following comment on app/api/forms/[id]/images/route.ts at line 122:

<comment>The response for an updated image returns the pre-update snapshot from `existingImage[0]`, so clients receive stale storageUrl and metadata despite the database having new values. Build the response from the fresh data you just persisted.</comment>

<file context>
@@ -0,0 +1,206 @@
+    });
+
+    return NextResponse.json(
+      { image: existingImage.length &gt; 0 ? existingImage[0] : imageData },
+      { status: existingImage.length &gt; 0 ? 200 : 201 }
+    );
</file context>
Fix with Cubic

{ status: existingImage.length > 0 ? 200 : 201 }
);
} catch (error) {
console.error("Error uploading image:", error);
return NextResponse.json(
{ error: "Failed to upload image" },
{ status: 500 }
);
}
}

// PUT /api/forms/[id]/images - Update image annotations
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;
const body = await request.json();
const { imageId, annotations } = body;

if (!imageId || !annotations) {
return NextResponse.json(
{ error: "Image ID and annotations are required" },
{ status: 400 }
);
}

// Check if image exists and belongs to user's form
const existingImage = await db
.select({
image: formImages,
form: forms,
})
.from(formImages)
.leftJoin(forms, eq(formImages.formId, forms.id))
.where(
and(eq(formImages.id, imageId), eq(forms.userId, session.user.id))
)
.limit(1);

if (existingImage.length === 0) {
return NextResponse.json({ error: "Image not found" }, { status: 404 });
}

// Update annotations
await db
.update(formImages)
.set({
annotations,
updatedAt: new Date(),
})
.where(eq(formImages.id, imageId));

// Create audit log
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: "update",
entityType: "image",
entityId: imageId,
ipAddress: request.ip || null,
userAgent: request.headers.get("user-agent") || null,
metadata: { action: "annotations_updated" },
timestamp: new Date(),
});

return NextResponse.json({ message: "Annotations updated successfully" });
} catch (error) {
console.error("Error updating annotations:", error);
return NextResponse.json(
{ error: "Failed to update annotations" },
{ status: 500 }
);
}
}
Loading