Skip to content
2025Team competition project

Laporin

A web platform for submitting violence reports without a normal user account, using AI-assisted triage to help officers prioritize cases and continue follow-up through real-time chat.

GitHub

Role
Full-stack development
Contribution
Backend, AI triage, officer authentication and dashboard, real-time chat
Key challenges
Key-based report access, encrypted reporting, AI-assisted triage, real-time communication
Tech stack
Next.js, Express, Socket.IO, Gemini

Overview

Laporin was a team project we built for GEMASTIK XVIII around violence reporting. We focused on a problem that can make reporting especially difficult: someone may already be dealing with fear or trauma while also worrying about whether their identity will stay private.

We wanted to remove at least one part of that concern. A reporter should not have to attach an email, password, or normal account identity before they can tell someone what happened.

That pushed us toward an accountless reporting flow. Instead of creating a conventional user profile, the reporter receives a seed phrase that lets them return to the same report later. This does not remove every privacy risk, but it avoids directly tying the report to a normal account that could identify them.

Once a report is submitted, the backend turns the free-form text into a summary, category, urgency level, and recommended destination for follow-up. This gives officers a quicker way to understand incoming cases and notice which ones may need attention sooner. The reporter can then return with the same seed phrase and continue the conversation through real-time chat.

My work was mostly on the backend, including the AI analysis and routing flow, officer authentication, and the real-time chat refactor. I also worked across the frontend on the reporting flow, officer dashboard, authentication, and chat integration.

  1. Write report
  2. Encrypted submission
  3. AI triage
  4. Officer review
  5. Real-time follow-up

Reporting without creating an account

The first problem we encountered was how someone could submit a report and still come back to it later without having a normal user account.

We borrowed the idea of a recovery phrase from cryptocurrency wallets. Instead of giving the reporter a username and password, the app generates a 12-word seed phrase that they keep themselves.

That phrase is generated on the client and deterministically turned into an X25519 key pair. The public key is stored with the report and can later be used to find it again, while the same seed phrase recreates the reporter's private key when they return.

When the reporter comes back, they enter the phrase instead of an email and password. The frontend derives the same key pair again, looks up the report using the public key, and uses the private key when decrypting its contents.

However, that comes with a trade-off: there is no normal account-recovery flow. If the reporter loses the phrase, the application cannot reconstruct it for them.

Encrypting the report before submission

The seed phrase solved how the reporter could return to a case, but the report itself could contain highly sensitive information. We did not want that text to be sent and stored as ordinary plaintext.

The encryption happens in two steps.

First, the reporter and the institution each have their own public and private key pair. The reporter's pair can be recreated from the seed phrase, while the reporter's public key can safely be stored with the report.

The two sides can then derive the same secret without ever sending that secret directly to each other. The reporter combines their private key with the institution's public key using X25519. The other side does the reverse: it combines its private key with the reporter's public key.

Both operations produce the same shared secret.

Reporter sideReporter private key + institution public key
Institution sideInstitution private key + reporter public key
X25519Key agreement
X25519Key agreement
Shared secretSame value on both sides
AES-GCMEncrypts and decrypts the report
Reporter sideReporter private key + institution public key
Institution sideInstitution private key + reporter public key
X25519Key agreement
X25519Key agreement
Shared secretSame value on both sides
AES-GCMEncrypts and decrypts the report
  • Reporter side connects to X25519.
  • Institution side connects to X25519.
  • X25519 connects to Shared secret.
  • X25519 connects to Shared secret.
  • Shared secret connects to AES-GCM.

The shared secret itself does not encrypt the report. It becomes the key used by AES-GCM, which handles the actual encryption and decryption of the report contents.

The backend therefore receives and stores ciphertext rather than the original report text.

There is one important boundary, though. The backend still needs to analyze the report, so it decrypts the contents before sending them to the AI model. This means the application server remains part of the trust boundary during triage rather than being completely unable to access the report contents.

Turning free-form reports into something officers can act on

Once reports start arriving, another problem appears: officers should not have to read every full report before they can tell which cases might need attention first.

Some reports may describe immediate danger, while others still need follow-up but are less time-sensitive. If every case only appears as a block of free-form text in submission order, an urgent report can be harder to notice quickly.

We wanted the dashboard to give officers a useful first view of each case. The backend therefore analyzes each report into four pieces of information:

  • a short summary,
  • an incident category,
  • an urgency level,
  • and a recommended destination for follow-up.

I implemented that analysis flow using Gemini 2.0 Flash.

The goal was not to let the model make the final decision about a case. It was to turn an unstructured report into a first layer of triage that officers could scan and then investigate themselves.

Example triage result
summary
Reporter describes recent physical violence and says they are afraid to return home.
category
Domestic violence
urgency
High
recommended destination
Victim-support service

The summary gives an officer a shorter version of the report to scan, while category and urgency make incoming cases easier to group and prioritize.

The recommended destination makes the result actionable. Rather than asking the model to freely decide where a report should go, the analysis prompt follows a priority policy. Reports suggesting immediate danger are directed toward law-enforcement handling, while cases centered on safety, counseling, or shelter are directed toward victim-support services.

The backend then uses that result to decide which officer queue receives the report. The specific institutions are less important than the boundary: the model produces structured triage data, and normal application logic decides what to do with it afterward.

Because that result feeds directly into the application, I did not leave the model output as arbitrary prose. Gemini is asked to return a fixed JSON structure, and fields such as category, urgency, and destination map to a limited set of application states.

Continuing the conversation after submission

Submitting a report was only the beginning of the flow. The reporter still needed a way to come back and communicate with the officer handling the case without switching to a conventional account-based system.

That made each report more than something stored in the database. It also had to become its own ongoing conversation.

Keeping each conversation isolated in real time

Our first chat implementation used raw WebSockets. Sending a message itself was straightforward, but managing the connections around it became harder as the flow grew.

The server had to keep track of which connection belonged to which report, distinguish the reporter from the officer, send events only to people participating in the same case, and clean up that state when someone disconnected. Much of that relationship between connections and reports was being managed manually.

I later refactored the chat on both the backend and frontend to Socket.IO. Instead of maintaining those groups ourselves, each report became its own room.

When someone opens a case, their socket joins that report's room. The server loads the existing message history, new messages are broadcast to participants in that room, and presence updates can follow the same connection lifecycle.

ReporterReturns through the same report
OfficerHandles the case
Socket.IO roomOne room per report
Encrypted messagesDelivered within the report
Message historyEncrypted payloads persisted by the server
ReporterReturns through the same report
OfficerHandles the case
Socket.IO roomOne room per report
Encrypted messagesDelivered within the report
Message historyEncrypted payloads persisted by the server
  • Reporter connects to Socket.IO room: joins.
  • Officer connects to Socket.IO room: joins.
  • Socket.IO room connects to Encrypted messages.
  • Encrypted messages connects to Message history.

The encryption from the reporting flow also carries into the chat. Before a message is sent, the client derives the same shared secret and uses it with AES-GCM to encrypt the content. The server stores and broadcasts the encrypted payload, while the receiving client derives the same secret and decrypts it locally.

In other words, Socket.IO handles where the message should go, while the cryptographic layer handles what the server receives and stores.

The current key-management design still keeps the application backend inside the trust boundary because institutional private-key material is available through the server. I would redesign that before describing the implementation as server-blind end-to-end encryption.

Reflection

Looking back, I think the current implementation is pretty solid for a competition prototype. The main pieces work together as one flow: reporters can return without a conventional account, the backend turns free-form reports into structured triage data, and officers can continue the case through real-time chat.

I would still keep most of those decisions if I revisited the project. In particular, I like keeping reporter access separate from officer authentication and limiting the model to a narrow, structured role instead of letting it control the whole workflow.

The chat refactor also changed how I thought about real-time systems. The difficult part was not simply sending a message from one browser to another. It was managing the lifecycle around that message: which case a connection belongs to, who should receive each event, what happens when someone reconnects, and how that state gets cleaned up.

What I would change now is mostly around the guarantees behind the flow. A real reporting system would need stronger validation of the triage logic, stricter authorization and key management, and a more durable way to run background analysis.

Making the triage safer to rely on

The routing policy in the prototype was based on the rules and examples we defined for the competition. I would not treat that as evidence that the model is accurate enough for real cases.

If I continued the project, I would build an evaluation set reviewed by people with relevant domain knowledge and measure how often the system assigns the expected category, urgency, and destination.

For uncertain or high-risk cases, I would also introduce a human-review path rather than letting the model's first output determine the next step automatically.

There is a reliability issue in how that analysis currently runs too. The application saves the report, returns the response, and then starts analysis from the running server process.

That was enough for the prototype, but if the process stops before analysis finishes, there is no persistent job waiting to continue it. For a real deployment, I would move the analysis into a durable queue or database-backed job so failures can be retried independently.

Tightening the trust boundary

The current implementation also has authorization and key-management boundaries that I would not keep in production.

The officer's report list is authenticated and filtered, but individual report access should enforce the same authorization directly. Socket.IO room joins should also verify that the participant is actually allowed to access that report rather than trusting the room identifier supplied by the client.

The bigger change would be key management. If the goal were for the application server to be unable to read conversations, institutional private keys could not remain available through the normal backend.

I would redesign where those keys live before making stronger privacy claims about the system.

Revisiting ideas we did not implement

Our original competition proposal went further than the version we eventually built.

One idea was to keep the reporter's identity separate from the report itself. An officer could handle the case without automatically knowing who submitted it, and the reporter could decide later whether to reveal that information. We did not implement that flow, but I still like the principle behind it: submitting a report should not automatically mean giving up control over your identity.

We also considered blockchain-based timestamping for report integrity. Looking back, I think I was too focused on the technology and not enough on the actual requirement, which was making sure changes to a report could be detected and that there was a trustworthy history of those changes.

If I approached it again, I would start from that requirement first, then compare simpler options such as append-only audit logs or cryptographically signed records before deciding whether blockchain was actually necessary.

That shift in thinking is what I would carry forward from Laporin. In a system handling sensitive information, adding more technology does not automatically make the design stronger. The harder part is deciding what each part of the system should be allowed to know, where trust has to exist, and which mechanisms are actually necessary to enforce those boundaries.