Motivation and Need
We've all experienced institutions — colleges, companies, organizations — where management collects feedback.
But when it comes to submitting complaints, the process often feels tedious, intimidating, or unclear.
Sometimes it's so complicated that people just give up.
Even if you do manage to submit a complaint, there's no guarantee you won’t face backlash from the administration or individuals involved.
⚡ I wanted to solve this.
I wanted to make a transparent and safe feedback process where:
- The admin can invite people to submit feedback or complaints,
- But the identity of the user remains hidden,
- Ensuring honest feedback without fear.
Thus, I built an Anonymous Feedback Backend system.
Current Mechanism
From what I’ve seen, feedback is often collected using tools like Google Forms.
At first glance, this seems convenient.
But if you look deeper, it’s actually flawed — and here’s why:
- Most forms collect your email automatically.
- Every feedback you submit gets tied to your email.
- Emails are deeply personal — they can be used to identify you easily.
This completely defeats the purpose of honest feedback.
If users know their identity could be revealed —
they will hesitate, filter their words, or worse, stay silent.
And beyond identification, there’s another hidden problem:
There’s no other proper way to verify that the feedback is authentic without collecting personal data.
This is the gap I wanted to fix.
Building a system that ensures:
- Only invited users can submit feedback,
- But their identity stays completely hidden,
- Enabling true, fearless feedback.
My Solution
I built an app that tries to fill this gap.
Let’s quickly walk through how it works:
- As usual, an admin creates an account.
- Then, the admin creates topics — each with a title and description — for which they want to collect feedback.
- Now, the admin uploads user details (
name
,email,
andtopicId
— which is visible on the frontend). -
After the emails are uploaded, a token is generated for each user.
This token is used for verification but is not associated with your email inside the system.
-
Users receive the token on their email.
When they want to submit feedback, they must enter both their email and the token.
Once verified, users can safely submit feedback anonymously.
Note:
The email is only used once — just to verify that you actually received the invitation.
When submitting feedback, only the token is sent — no personal email data is stored or exposed.
#Relax 😌
Er Diagram
How the Database Design Protects User Privacy
Let's begin with how feedback is only tied to the topic
table.
In the feedback
table, you can clearly see that no personal data like user email is stored.
Admins can view submitted feedbacks using only the topicId
and their own adminId
. Beyond that, there is no way to trace which email submitted which feedback.
Challenges:
Challenge 1: Validating legitimate users without storing personal data
To solve this, we require the user to submit both their email and token when giving feedback.
- The
EmailTopics
table validates whether this email was invited for that topic. - The token is unique and tied to a specific
topicId
. It allows us to authorize feedback submissions without exposing personal user info.
Challenge 2: Supporting multiple feedback invitations for the same user
One user may be invited to submit feedback on multiple topics.
- To support this, we designed a one-to-many relationship:
- One email → Many topics (
EmailTopics
table) - One topic → Many emails (
EmailTopics
table)
- One email → Many topics (
Thus, this approach balances admin control, user privacy, and flexibility.
How Feedback Submission Works Under the Hood
We use both email and token to validate each end user during feedback submission.
- Email helps us verify whether the user was actually invited to submit feedback.
-
Token helps us validate multiple things:
- Whether the token is valid and exists.
- Whether it is tied to a specific
topicId
. - Whether the topic still exists (it could have been deleted by the admin midway).
- Whether the user has already submitted feedback (if yes, they won't be allowed to submit again).
This dual validation ensures feedback submissions are secure, one-time, and tied only to authorized users, without storing any personal information inside the feedback itself.
const loginUsingToken = async (req: Request, res: Response):Promise<void> => {
try {
const { email, token } = userLoginSchema.parse(req.body);
const [searchEmail, searchToken] = await Promise.all([
prisma.email.findUnique({ where: { email } }),
prisma.token.findUnique({ where: { token } }),
]);
if (!searchEmail || !searchToken) {
res.status(400).json({
success: false,
message: "Auth failure against token and email provided.",
});
return;
}
// Check token expiry
if (new Date() > searchToken.expiresAt) {
res.status(409).json({
success: false,
message: "We are no longer accepting feedback. Feedback time is over.",
});
return;
}
// Check if token is used
if (searchToken.isUsed) {
res.status(409).json({
success: false,
message: "Token has already been used to submit feedback.",
});
return;
}
// Check if email is associated with the topic from token
const isAssociated = await prisma.email.findFirst({
where: {
id: searchEmail.id,
topics: {
some: { id: searchToken.topicId },
},
},
});
if (!isAssociated) {
res.status(401).json({
success: false,
message: "You are not allowed to submit feedback to this topic.",
});
return;
}
// All good
res.json({
success: true,
message: "Logged in successfully",
tokenId: searchToken.id,
topicId: searchToken.topicId,
});
} catch (error) {
console.error("Error during token login:", error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
res.status(500).json({
success: false,
message: "Internal server error happened at our side (DB)",
});
return;
}
res.status(500).json({
success: false,
message: "Internal server error happened at our side.",
});
return;
}
};
Mistakes I Made in v0
-
I totally forgot to add a logout route for the admin.
(The only way out was letting the JWT expire — not a great thing to do, apparently.)
-
I created three separate routes:
- Uploading emails
- Generating tokens
-
Sending emails
This created a lot of friction for the end users, who now had to make too many decisions manually.
-
I was using memory storage for processing uploaded CSV files.
This was not scalable — I should have used a blob or file service.
Since I was saving uploads to a local
/uploads
folder, it broke in deployment (because cloud servers have temporary file storage that often resets or errors out). Looking back...
This gives me chills — a lot of rookie mistakes were made here.
What I Fixed in v1
-
For the auth system — I didn’t just create a logout endpoint.
I went beyond expectations. (Not just flexing, it’s the real deal, brahh. 😎)
-
I built a full authentication system with:
- Refresh Tokens
- Session Management
- IP and device tracking
- Expiry tracking for each session
- Secure storage of refresh tokens inside
**HttpOnly
cookies**.
-
On logout, we now clear the refresh token (mark it revoked), terminate the session, and save when/where/how it was terminated.
This makes the auth system robust, secure, and modern in every sense.
About the endpoints problem:
I reduced three endpoints to just two.
I automated two flows and merged them together.
Again, went a bit beyond normal expectations.
-
I used transactions to merge uploading emails and generating tokens into a single atomic operation.
Now it’s automated, more robust, and error-resistant
For the storage problem:
- I stopped using disk storage.
- I used memory storage instead.
- Once the file is available in RAM, we parse it directly from buffers and send all the email data using a middleware function — no more temp files!
✅ This made the whole system faster, more scalable, and ready for real-world deployment.
Talk Is Cheap Show me the code PRASHANT you flex too much we shall see your code 🤔
Logout endpoint code
const logoutAdmin = async (req:Request, res:Response)=>{
try {
const refreshToken = req.cookies.refreshToken;
if(!refreshToken){
res.status(400).json({
success:false,
message:"No refresh cookie could be found"
})
return;
}
const validateRefreshToken = await prisma.refreshToken.findUnique({
where:{
id:refreshToken
}
})
if(!validateRefreshToken){
res.status(400).json({
success:false,
message:"Invalid refresh token!"
})
return;
}
else if(validateRefreshToken.revoked == true ||validateRefreshToken.expiresAt <= new Date()){
res.status(400).json({
success:false,
message:"Expired or already used refresh tokens"
})
return;
}
await prisma.refreshToken.update({where:{id:validateRefreshToken.id}, data:{revoked:true}})
await prisma.session.update({where:{refreshToken:validateRefreshToken.id}, data:{revoked:true, revokedAt: new Date()}})
res.clearCookie("refreshToken")
res.json({
success:true,
message:"Logged out successfully"
})
} catch (error) {
console.error("Error happened while logging out", error)
res.status(500).json({
success:false,
message:"Internal server error"
})
}
}
🔄 Refresh Access Token Endpoint
Since my access tokens are short-lived (for better security),
I built a refresh access token endpoint to allow users (admins) to stay logged in without forcing them to sign in again and again.
Just to be honest — I added this because I felt like it 😎 — but also because real-world auth flows need this for a good user experience.
This endpoint checks the refresh token (securely stored in HttpOnly
cookies),
verifies it, and issues a new access token without touching the user session unnecessarily.
It's a small addition but it makes the auth system feel buttery smooth.
const renewAccessToken = async (req:Request, res:Response)=>{
try {
const refreshToken = req.cookies.refreshToken;
if(!refreshToken){
res.status(400).json({
success:false,
message:"Refresh Token in unavailable"
})
return;
}
const validateRefreshToken = await prisma.refreshToken.findUnique({
where:{
id:refreshToken
}
})
if(!validateRefreshToken){
res.status(400).json({
success:false,
message:"Invalid refresh token!"
})
return;
}
else if(validateRefreshToken.revoked == true ||validateRefreshToken.expiresAt <= new Date()){
res.status(400).json({
success:false,
message:"Expired or already used refresh tokens. Please login again."
})
return;
}
const adminDetails = await prisma.admin.findUnique({
where:{
id:validateRefreshToken.adminId
}
})
const accessToken = jwt.sign(
{
name: adminDetails?.name,
id: validateRefreshToken.adminId,
email: adminDetails?.email,
},
JWT_SECRET,
{
algorithm: "HS256",
issuer: "ADMIN",
expiresIn: "15m",
}
);
res.json({
success:true,
message:"Token generated successfully",
accessToken
})
} catch (error) {
console.error("Error happened while refreshing access token", error);
res.status(500).json({
success:false,
message:"Internal server error happened at our end"
})
}
}
Finally, the best thing — saved for last:
I love the Prisma developers from the bottom of my heart.
They made atomicity and transactions come to life with such simple syntax.
I literally jumped out of my chair when I got my transaction code working for the first time! 😄
Until now, I had only heard about rollback, atomicity, and consistency...
but implementing it myself felt absolutely surreal.
Big corporations use transactions to power things like UPI payments, net banking, and huge financial systems.
And today, my small little app uses it too. ✨
Enough talk — here’s the code.
Go ahead, implement it yourself.
Fall in love with the beauty of low-level programming. 🚀
const saveEmails = async (req:RequestWithPayload, res:Response)=>{
try {
const id:number = req.user?.id!
const topicId = req.params.id; // to create and save tokens
if(!topicId){
res.status(400).json({
success:false,
message:"To send emails in next process. it needs topicId as params."
})
}
const emailsWithTopics = req.emailsWithTopics
console.log(emailsWithTopics, "email with topics")
const dataToInsert = (req.emailsWithTopics ?? []).map(entry => ({
name: entry.name || null,
email: entry.email,
topicId: parseInt(entry.topicId),
adminId: id,
}))
// Main Part
await prisma.$transaction(async(tx)=>{
for (const entry of dataToInsert){
const email = await tx.email.upsert({
where:{
email: entry.email
},
update:{
name:entry.name
},
create:{
email:entry.email,
name:entry.name,
admin: {connect: {id: entry.adminId}} //adminId:entry.adminId
}
})
// first we saved each emails
// now we update each emails so that it can be associated with topics
await tx.email.update({
where:{
id:email.id
},
data:{
topics: {connect: {id: parseInt(topicId)}}
}
})
}
const emailCount = await tx.email.count({
where:{
topics:{
some:{
id: parseInt(topicId)
}
}
}
})
// we counted no of emails created/saved now will generate unique token for this
// generateTokens against emails
const nTokens = await createNToken(emailCount);
// mapout tokens to save in database
const tokenToInsert = nTokens.map((val)=>({
token:val,
topicId:parseInt(topicId),
expiresAt:new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}))
// save tokens to database
await tx.token.createMany({
data: tokenToInsert,
})
// finally end of transaction.
})
res.status(200).json({
success:true,
message:"Emails and Tokens are successfully added to database 🥳"
})
} catch (error) {
console.error("Error happened at processing transaction 😔", error);
res.status(500).json({
success:false,
message:"Internal server error"
})
return;
}
}
🎤🎙️End
This is why we build.
This is why we code.
This is why we fall in love with tech.
I have add GitHub link and postman link, go fork it make it bad ,ugly mess, weep, cry fix it.
GitHub Link:-https://github.com/PRASHANTSWAROOP001/-Anonymous-Feedback-System
Postman Link:- https://documenter.getpostman.com/view/38176982/2sB2j1gXe1
Top comments (4)
Insane level of detail, honestly I wish I had something like this back when I was dealing with anonymous feedback nightmares in college.
Thanks 💗 for the feedback.
Great post!
Thanks I Hope You Have Enjoyed It. 🤗💖