Skip to content

Comments

Allow updating files (instead of overriding) for multiple file uploads#191

Closed
janezd wants to merge 1 commit intobiolab:masterfrom
janezd:allow-update-uploads
Closed

Allow updating files (instead of overriding) for multiple file uploads#191
janezd wants to merge 1 commit intobiolab:masterfrom
janezd:allow-update-uploads

Conversation

@janezd
Copy link
Contributor

@janezd janezd commented Feb 11, 2026

Fixes #177.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request implements the ability to add multiple files to upload-type questions without replacing existing files, along with the ability to remove individual files. This addresses issue #177 which requested allowing additional uploads when answering upload-type questions.

Changes:

  • Modified the upload answer logic to merge newly uploaded files with existing files instead of replacing them
  • Added a removeUserFile API function to allow deleting individual uploaded files
  • Updated the UI to display uploaded files with delete buttons when editing is allowed
  • Changed the upload directory handling to preserve existing files during new uploads

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
next.config.ts Removed boilerplate comment
context/QuizContextProvider.tsx Added removeFile function to context, updated to use storedAnswer from backend
components/Quiz/UploadQuestion.tsx Added UI for displaying and removing uploaded files, updated file selection logic
components/Quiz/Quiz.tsx Removed TODO comment
app/api/upload-answer/route.ts Changed to create directory if needed instead of removing and recreating
api/quiz.ts Added removeUserFile function, modified postAnswer to merge uploaded files
.gitattributes Added merge strategy for auto-generated file
Comments suppressed due to low confidence (3)

api/quiz.ts:84

  • Avoid automated semicolon insertion (92% of all statements in the enclosing function have an explicit semicolon).
    : question.answer.trim().toLocaleLowerCase() === answer.trim().toLocaleLowerCase()

components/Quiz/Quiz.tsx:87

  • Avoid automated semicolon insertion (90% of all statements in the enclosing function have an explicit semicolon).
          msg += ` ${t("quiz.remaining")}: ${maxAttempts - attempts}`

context/QuizContextProvider.tsx:264

  • Avoid automated semicolon insertion (94% of all statements in the enclosing function have an explicit semicolon).
      })

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +141 to +143
<div className="flex flex-col text-red-500" style={{lineHeight: "1.4"}}>
<small className="form-text text-muted">
{t("quiz.submit-will-replace")(files.length)}
{t("quiz.dont-forget-to-submit-file")(files.length)}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The container div has the class "text-red-500" to make text red, but the small elements inside have class "text-muted" which likely overrides the red color. The red color class should be moved to the individual small elements or removed from the parent if it's not needed.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +94
<RiDeleteBin2Line
onClick={() => onRemoveUploadedFile(file)}
style={{cursor: "pointer"}}
className="hover:text-red-700"
/>
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The delete icon buttons are not keyboard accessible and lack proper ARIA labels. Users navigating with a keyboard cannot tab to these buttons, and screen reader users won't know what the buttons do. Consider wrapping the icon in a button element with proper aria-label, or adding tabIndex={0} and onKeyDown handlers to support keyboard navigation.

Copilot uses AI. Check for mistakes.

const answer = await removeUserFile({
accessToken: user.accessToken, bookId, questionId, groupId, fileName});
if (answer === null) {
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The removeFile callback doesn't handle errors properly. If removeUserFile returns null (indicating failure), the function returns false but doesn't set an error state in the reducer. Users won't see any feedback explaining why the file wasn't removed. Consider adding error handling similar to the uploadFiles function that calls quizReducer with type "ERROR" when the operation fails.

Suggested change
if (answer === null) {
if (answer === null) {
quizReducer({type: "ERROR", value: {questionId}});

Copilot uses AI. Check for mistakes.
isCorrect: undefined,
points: 0
}
});
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The closing brace has inconsistent indentation with 7 spaces instead of 8 (like the opening brace on line 289). This should be aligned properly for consistency.

Suggested change
});
});

Copilot uses AI. Check for mistakes.
Comment on lines +30 to 32
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The change from removing and recreating the directory to just creating it if it doesn't exist means that if a user uploads a new file with the same name as an existing file, the new file will silently overwrite the old one in the file system. However, the database merge logic in api/quiz.ts (line 91) filters out duplicates with !pastFiles.includes(f). This means the behavior is correct for preventing duplicate entries in the database, but users should be aware that re-uploading a file with the same name replaces the previous version.

Copilot uses AI. Check for mistakes.
fs.rmSync(fname);
}
}
const newAnswers = answers.filter((f) => f !== fileName).join(":");
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

When all files are removed, the filter operation will create an empty array, and join(":") will return an empty string. However, if there's ever a case where answer is already an empty string, split(":") returns [""], and if fileName is also "", this could match and try to filter it out. Consider adding a check to handle empty answers more explicitly, or filter out empty strings after split: answers.filter(f => f && f !== fileName)

Suggested change
const newAnswers = answers.filter((f) => f !== fileName).join(":");
const newAnswers = answers.filter((f) => f && f !== fileName).join(":");

Copilot uses AI. Check for mistakes.
Comment on lines 108 to 112
<RiDeleteBin2Line
onClick={() => onRemoveFile(f.name)}
style={{cursor: "pointer"}}
className="hover:text-red-700"
/>
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

Similar to the uploaded file delete button, this delete icon for selected files is not keyboard accessible and lacks proper ARIA labels. Consider wrapping the icon in a button element with proper aria-label, or adding tabIndex={0} and onKeyDown handlers to support keyboard navigation.

Copilot uses AI. Check for mistakes.
Comment on lines +332 to +353
return null;
}
const answers = lastAnswer.answer.split(":");
if (!answers.includes(fileName)) {
return null;
}

const {dir, error} = await getUploadDir(
{accessToken, bookId, groupId, qId: questionId});
if (dir && !error) {
const fname = path.join(dir, fileName);
if (fs.existsSync(fname)) {
fs.rmSync(fname);
}
}
const newAnswers = answers.filter((f) => f !== fileName).join(":");
await db.run(`
UPDATE answers
SET answer = ?
WHERE id = ?`,
[newAnswers, lastAnswer.id]
);
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The removeFile function has a potential race condition. If the database update succeeds but the file system deletion fails (or vice versa), the system state becomes inconsistent. Consider either handling errors from fs.rmSync or wrapping the entire operation in a transaction-like pattern. Additionally, if answer is null, the function returns false but doesn't provide any error feedback to the user.

Suggested change
return null;
}
const answers = lastAnswer.answer.split(":");
if (!answers.includes(fileName)) {
return null;
}
const {dir, error} = await getUploadDir(
{accessToken, bookId, groupId, qId: questionId});
if (dir && !error) {
const fname = path.join(dir, fileName);
if (fs.existsSync(fname)) {
fs.rmSync(fname);
}
}
const newAnswers = answers.filter((f) => f !== fileName).join(":");
await db.run(`
UPDATE answers
SET answer = ?
WHERE id = ?`,
[newAnswers, lastAnswer.id]
);
console.warn("removeUserFile: no previous answer found for given parameters", {
bookId,
groupId,
questionId,
fileName,
});
return null;
}
const answers = lastAnswer.answer.split(":");
if (!answers.includes(fileName)) {
console.warn("removeUserFile: requested file name not found in last answer", {
bookId,
groupId,
questionId,
fileName,
});
return null;
}
const oldAnswers = lastAnswer.answer;
const newAnswers = answers.filter((f) => f !== fileName).join(":");
// First, update the database. If this fails, do not touch the filesystem.
try {
await db.run(`
UPDATE answers
SET answer = ?
WHERE id = ?`,
[newAnswers, lastAnswer.id]
);
} catch (err) {
console.error("removeUserFile: failed to update answers in database", {
error: err,
answerId: lastAnswer.id,
});
return null;
}
// After a successful DB update, attempt to remove the file. On failure, try to roll back the DB change.
const {dir, error} = await getUploadDir(
{accessToken, bookId, groupId, qId: questionId});
if (dir && !error) {
const fname = path.join(dir, fileName);
if (fs.existsSync(fname)) {
try {
fs.rmSync(fname);
} catch (fsErr) {
console.error("removeUserFile: failed to delete file from filesystem, attempting to roll back DB change", {
error: fsErr,
filePath: fname,
answerId: lastAnswer.id,
});
try {
await db.run(`
UPDATE answers
SET answer = ?
WHERE id = ?`,
[oldAnswers, lastAnswer.id]
);
} catch (rollbackErr) {
console.error("removeUserFile: failed to roll back database after filesystem error; state may be inconsistent", {
error: rollbackErr,
answerId: lastAnswer.id,
});
}
return null;
}
}
}

Copilot uses AI. Check for mistakes.
return {status: "error", message: "Maximum number of attempts reached"};
}

const pastFiles = question.type !== "uploads" || !pastAnswers.length ? []
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

The condition question.type !== "uploads" only checks for the plural form, but according to the codebase, upload questions can have type "upload" (singular) or "uploads" (plural). This means the file merging logic won't work for singular "upload" type questions. The condition should be question.type !== "uploads" && question.type !== "upload" or use !question.type.startsWith("upload") to handle both cases.

Suggested change
const pastFiles = question.type !== "uploads" || !pastAnswers.length ? []
const pastFiles = (question.type !== "uploads" && question.type !== "upload") || !pastAnswers.length ? []

Copilot uses AI. Check for mistakes.
</>
}
}
</>
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

Avoid automated semicolon insertion (90% of all statements in the enclosing function have an explicit semicolon).

Suggested change
</>
</>;

Copilot uses AI. Check for mistakes.
@janezd
Copy link
Contributor Author

janezd commented Feb 19, 2026

Closed in favor of #198.

@janezd janezd closed this Feb 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider allowing additional uploads when answering upload-type questions

1 participant