diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..23784cb
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.cs]
+indent_size = 4
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.env.example b/.env.example
index 7f0a92f..ec682e9 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,5 @@
# Example environment for ProCoderJii Notes. Copy to .env.local for local development.
-# Nothing here is secret: both values are public. Never put passwords or API keys in this file.
+# Active example values are public. Never put passwords or private keys in this file.
# For production, set these in your hosting provider's environment settings (see docs/deployment.md).
# Public id of the @Pro_coder_ji YouTube channel. Enables the "Latest videos" section on the
@@ -9,3 +9,9 @@ YOUTUBE_CHANNEL_ID=UC74q2pzSGuoaikYwnavcGMg
# Canonical site origin. Optional: it defaults to https://procoderji.com, so only set it to override
# (for example on a staging build). It is compiled in at build time.
# NEXT_PUBLIC_SITE_URL=https://procoderji.com
+
+# Optional ASP.NET Core backend. This URL is server-only; it is never sent to the browser.
+# Leave unset for free guest practice with browser-local progress.
+# Google client secrets belong in .NET user-secrets or the backend host's secret configuration.
+# See backend/README.md for Google OAuth, database setup, and deployment.
+# DOTNET_API_URL=http://localhost:5100
diff --git a/.github/ISSUE_TEMPLATE/practice_question.yml b/.github/ISSUE_TEMPLATE/practice_question.yml
new file mode 100644
index 0000000..d2a6c41
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/practice_question.yml
@@ -0,0 +1,59 @@
+name: Practice question proposal
+description: Propose an original, explained exercise for student placement practice.
+title: "practice: "
+labels: [enhancement]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Check existing questions and issues first. A useful proposal includes a correct answer,
+ an explanation, and clear authorship or permission. Do not paste a private assessment
+ or copyrighted question bank without permission.
+ - type: input
+ id: topic
+ attributes:
+ label: Topic and skill
+ description: Which topic and specific skill does this question help a student practise?
+ placeholder: "Percentages: successive percentage changes"
+ validations:
+ required: true
+ - type: textarea
+ id: audience
+ attributes:
+ label: Student audience and difficulty
+ description: Describe the intended learner, prerequisites, and difficulty.
+ placeholder: "Students starting campus-placement aptitude practice; basic percentage knowledge."
+ validations:
+ required: true
+ - type: textarea
+ id: question
+ attributes:
+ label: Question and options
+ description: Include the full question and answer options, if applicable. State units and assumptions clearly.
+ validations:
+ required: true
+ - type: textarea
+ id: answer
+ attributes:
+ label: Correct answer and explanation
+ description: Show the reasoning, calculation, or code. Explain why the answer is correct and any common mistake.
+ validations:
+ required: true
+ - type: textarea
+ id: source
+ attributes:
+ label: Authorship, sources, and permission
+ description: State whether you wrote the question. Link any source and explain the permission or licence for adapting it.
+ validations:
+ required: true
+ - type: checkboxes
+ id: review
+ attributes:
+ label: Submission checks
+ options:
+ - label: I wrote this material or have permission under the stated licence to submit it.
+ required: true
+ - label: I checked the answer and included an explanation.
+ required: true
+ - label: This proposal contains no private student data, credentials, or confidential assessment material.
+ required: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 90e1e48..bbeebd7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,3 +42,89 @@ jobs:
- name: Production build
run: npm run build
+
+ site-quality:
+ name: Browser and SEO checks
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: npm
+
+ - name: Set up .NET for browser checks
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Check desktop and mobile browser behavior
+ env:
+ E2E_DOTNET: '1'
+ run: npm run e2e
+
+ - name: Crawl production build and verify SEO
+ run: npm run launch-check
+
+ - name: Upload browser failure screenshots
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: browser-screenshots-${{ github.run_attempt }}
+ path: e2e/.out/*.png
+ include-hidden-files: true
+ if-no-files-found: ignore
+ retention-days: 7
+
+ backend:
+ name: Backend build and tests
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ # Fixed credentials are only for this disposable CI database.
+ POSTGRES_DB: procoderji_test
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d procoderji_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore backend dependencies
+ run: dotnet restore backend/ProCoderJi.slnx --locked-mode
+
+ - name: Build backend
+ run: dotnet build backend/ProCoderJi.slnx --configuration Release --no-restore
+
+ - name: Test backend
+ run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
+
+ - name: Verify PostgreSQL migrations
+ # Validates the PostgreSQL schema path; Google sign-in needs separate live checks.
+ env:
+ ASPNETCORE_ENVIRONMENT: Development
+ Database__Provider: Postgres
+ ConnectionStrings__Platform: 'Host=localhost;Port=5432;Database=procoderji_test;Username=postgres;Password=postgres'
+ PublicOrigin: http://localhost:3000
+ run: dotnet run --project backend/src/ProCoderJi.Api --configuration Release --no-build --no-launch-profile -- --migrate
diff --git a/.gitignore b/.gitignore
index b593755..1de6698 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,15 @@ __pycache__/
*.swp
Thumbs.db
desktop.ini
+
+# ASP.NET Core output, local databases, and test evidence
+**/bin/
+**/obj/
+**/TestResults/
+backend/**/.data/
+backend/**/*.db
+backend/**/*.db-shm
+backend/**/*.db-wal
+backend/**/appsettings.Local.json
+*.user
+*.suo
diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..a45fd52
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+24
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..a45fd52
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 504ffea..2ea17fc 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -1,50 +1,79 @@
-# Contributor Covenant Code of Conduct
+# Code of Conduct
## Our pledge
-We are committed to making participation in this project a respectful experience for everyone,
+We want students, first-time contributors, and experienced maintainers to participate in a
+welcoming, respectful community. We are committed to participation free from harassment,
regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics,
gender identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and orientation.
-We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive,
-and healthy community.
+## Expected behavior
-## Our standards
+- Be patient with questions and explain feedback clearly. Someone may be learning Git, English,
+ a programming concept, or open-source collaboration for the first time.
+- Review the contribution and its impact on students. Critique ideas and code respectfully.
+- Give credit, respect copyright, and avoid publishing another person's private information.
+- Accept constructive feedback, take responsibility for mistakes, and help resolve their impact.
+- Respect a request to stop an interaction or move a discussion to an appropriate channel.
-Examples of behavior that contributes to a positive environment include:
+Unacceptable behavior includes harassment, threats, stalking, deliberate intimidation,
+discriminatory or derogatory remarks, sexualized language or imagery, unwelcome sexual
+attention, personal attacks, and publishing private information without permission. Repeated
+disruption of discussions or retaliation against someone who raises a concern is also
+unacceptable.
-- Showing empathy and kindness toward other people.
-- Giving and accepting constructive feedback respectfully.
-- Taking responsibility, apologizing to those affected, and learning from mistakes.
-- Focusing on what is best for the community.
+## Where this applies
-Examples of unacceptable behavior include:
+This Code applies to the project's issues, discussions, pull requests, reviews, learning-content
+submissions, and other project-managed spaces. It also applies when someone officially
+represents ProCoderJii, for example through an official project account or an appointed role.
-- Sexualized language or imagery, and unwelcome sexual attention.
-- Trolling, insulting or derogatory comments, and personal or political attacks.
-- Public or private harassment, including deliberate intimidation or stalking.
-- Publishing another person's private information without permission.
-- Other conduct that would reasonably be considered inappropriate in a professional setting.
+## Report a concern
-## Enforcement responsibilities
+Email **procoderji@gmail.com**. Project maintainers use this channel to receive conduct reports.
+Please include the information you are comfortable sharing:
-Project maintainers are responsible for clarifying and enforcing these standards and may remove,
-edit or reject comments, commits, code, issue reports and other contributions that do not align
-with this Code. Maintainers may temporarily or permanently restrict participation for behavior
-they consider inappropriate, threatening, offensive or harmful.
+- What happened and where it happened.
+- Links, dates, or screenshots that help explain the incident.
+- People involved and whether the behavior is ongoing.
+- A way to contact you and any concern about how a response could affect you.
-## Scope
+You do not need to investigate the incident yourself. Leave out unrelated personal information,
+passwords, and private student records. Conduct reports should go through the email channel
+rather than a public issue or pull-request discussion.
-This Code applies within project spaces and when someone is officially representing the project
-in public spaces.
+Reports will be handled as privately as practical. Maintainers may need to share relevant
+information with people who help review or resolve the incident; identifying details should be
+limited to what is needed. Absolute confidentiality or anonymity cannot be guaranteed,
+especially when an incident is already public. Reporters should be told about necessary
+sharing when practical.
-## Reporting
+## Review and response
-Report unacceptable behavior to **procoderji@gmail.com**. Reports will be reviewed and handled
-confidentially. Maintainers will communicate the response to the reporter when appropriate.
+Maintainers review available information, consider the behavior and its impact, and may ask for
+clarification. A maintainer who is involved in a report should not decide its outcome. If another
+reviewer is unavailable, that limitation should be made clear to the reporter.
+
+The response should be proportionate to the severity, context, and recurrence of the behavior.
+It may include:
+
+1. **Correction:** explain the concern, request a change, or remove inappropriate content.
+2. **Warning:** state the behavior that must stop and any conditions for continued participation.
+3. **Temporary restriction:** limit participation in project spaces for a stated period.
+4. **Permanent restriction:** end participation when serious or repeated harm makes it necessary.
+
+These are available responses, not a required sequence. Serious behavior may need an immediate
+restriction. Maintainers may remove or reject comments, content, or contributions that violate
+this Code. They should communicate a decision and relevant participation conditions to those
+affected when appropriate, while limiting disclosure of private report details.
+
+Retaliation against a reporter, witness, or person helping resolve a report is a separate conduct
+concern. If you believe a decision overlooked relevant information, request a review through
+**procoderji@gmail.com** and explain the concern or new information.
## Attribution
-This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
-version 2.1, available at .
+This Code is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
+version 2.1, available at
+.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e4715f4..fa71722 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,44 +1,88 @@
# Contributing
-Thanks for helping improve ProCoderJii Learning Hub. This guide explains how to set up the
-project, check a change and open a pull request.
+Help ProCoderJii become a useful learning and placement-practice platform for students. Code,
+documentation, accessibility improvements, original questions, and clear bug reports are welcome.
-## Before you start
+## Find a useful change
-- Check existing issues and pull requests to avoid duplicate work.
-- For a large change, open an issue first so the scope can be discussed.
-- Follow the [Code of Conduct](CODE_OF_CONDUCT.md).
-- For a security vulnerability, use the private reporting instructions in [SECURITY.md](SECURITY.md),
- not a public issue.
+- Read the [project overview](README.md) and [documentation index](docs/README.md).
+- Check existing [issues](https://github.com/procoderji-com/procoderji/issues) and
+ [pull requests](https://github.com/procoderji-com/procoderji/pulls) before starting.
+- Discuss larger features, paid features, data collection, or dependency changes in an issue first.
+ Include the student problem and the smallest useful outcome.
+- Use the practice-question issue form to propose a new exercise. Include the answer,
+ explanation, audience, and authorship or source permission.
+- Follow the [Code of Conduct](CODE_OF_CONDUCT.md). Report security vulnerabilities through
+ [SECURITY.md](SECURITY.md).
-## Set up the project
+An issue being open does not reserve it for anyone. Leave a short comment when you start work so
+contributors can coordinate. Ask for clarification when an acceptance criterion is unclear.
-Use Node.js 22.18 or newer and npm:
+## Set up your fork
-```bash
-git clone https://github.com/Coding-Professional/procoderji.git
-cd procoderji
-npm ci
-npm run dev
-```
+Use Node.js **24** and npm. The version is recorded in `.nvmrc` and `.node-version`. The app can
+run locally without production credentials. Backend contributors also need the **.NET 10 SDK**;
+see [Development](docs/development.md) and [Backend setup](backend/README.md).
+
+1. Fork [procoderji-com/procoderji](https://github.com/procoderji-com/procoderji) on GitHub.
+2. Clone your fork, replacing `YOUR-USERNAME` with your GitHub username:
+
+ ```bash
+ git clone https://github.com/YOUR-USERNAME/procoderji.git
+ cd procoderji
+ git remote add upstream https://github.com/procoderji-com/procoderji.git
+ npm ci
+ npm run dev
+ ```
-Copy `.env.example` to `.env.local` only if you want to test the optional latest-video section.
-The documented values are public; do not add credentials or private tokens to the example file.
+3. Open . `origin` is your fork; `upstream` is the original project.
-## Make a change
+If you already cloned the original project, you can rename that remote to `upstream`, add your
+fork as `origin`, and continue from the same checkout.
+
+## Start a branch
+
+Update your local `main` before starting a new change:
+
+```bash
+git switch main
+git fetch upstream
+git merge --ff-only upstream/main
+git push origin main
+git switch -c feat/describe-the-change
+```
-- Keep each pull request focused on one problem or feature.
-- Use TypeScript and the existing component and utility patterns.
-- Keep controls usable with a keyboard and on a phone. Give form fields labels and interactive
- elements visible focus states.
-- Add or update a unit test for new data validation or reusable logic.
-- Keep study material original. See [docs/content-guide.md](docs/content-guide.md) before adding
- notes, guides or job listings.
-- Do not add secrets, private user data, generated build output or unlicensed assets.
+Use a short branch name such as `fix/practice-feedback` or `docs/question-authoring`. If the
+fast-forward merge fails, inspect your local commits before deciding how to reconcile the
+branches. Do not force-push over work you have not checked.
+
+## Make a focused change
+
+- Keep a pull request about one student problem or contributor improvement.
+- Use TypeScript and the existing UI patterns. Keep practice rules and scoring independent of
+ React, storage, authentication, and payment providers.
+- Put new practice-domain work under `src/features/practice/`; read the
+ [boundary decision](docs/decisions/0001-student-product-boundaries.md) before adding adapters.
+- Add original questions to `shared/practice/questions.json`, the canonical bank consumed by
+ both runtimes. Keep the TypeScript and .NET validation behavior consistent when its shape changes.
+- Put authentication, account persistence, and future server-owned capabilities under
+ `backend/`. The Next.js `/api/platform/*` layer forwards the browser's same-origin requests.
+- Keep practice usable on a phone and with a keyboard. Label fields, show focus states, and
+ provide useful feedback beyond color alone.
+- Add meaningful tests for new scoring, validation, persistence, or access rules. Documentation
+ changes do not need tests that simply repeat their text.
+- Keep learning material original. Follow the [content guide](docs/content-guide.md) before
+ publishing notes, guides, or job listings.
+- Keep secrets, private student data, generated output, and unlicensed material out of commits.
+
+Free browser practice remains available without an account. Optional account synchronization
+should enhance that experience and degrade clearly when its provider is not configured. New
+payment or account requirements need an agreed scope and a documented data flow.
## Check your change
-Run the checks that match the files you changed:
+Run the checks relevant to your change; the commands are explained in
+[Development](docs/development.md#checks).
```bash
npm run lint
@@ -47,19 +91,29 @@ npm test
npm run build
```
-For route, link and metadata changes, also run `npm run launch-check`. For browser interaction
-changes, run `npm run e2e` with Chrome installed. The E2E runner refuses to run if `src/data` or
-`src/content` has uncommitted changes because it temporarily installs synthetic fixtures. For
-content changes, run `npm test` and `npm run launch-check` instead.
+For backend or shared-question changes, also run:
+
+```bash
+dotnet test backend/ProCoderJi.slnx --configuration Release
+```
+
+For routes, links, or metadata, also run `npm run launch-check`. For browser interactions, run
+`npm run e2e` with Chrome installed. Browser checks temporarily install synthetic content and
+refuse to run if `src/data` or `src/content` has local changes. For content-only changes, use
+`npm test` and `npm run launch-check` instead.
+
+## Submit the pull request
-## Open a pull request
+```bash
+git add
+git commit -m "Describe the change"
+git push -u origin
+```
-1. Create a branch from `main` with a short, descriptive name.
-2. Make focused commits with clear messages.
-3. Open a pull request against `main` and fill in the provided template.
-4. Explain the user-facing change and list the checks you ran. Include screenshots for visual
- changes when useful.
-5. Keep the pull request up to date and respond to review feedback.
+Open a pull request from your fork branch to `procoderji-com/procoderji:main`. Fill in the
+template, link the related issue, explain the resulting behavior, and list the checks you ran.
+Include screenshots for visible UI changes and sources or permissions for learning content.
-The maintainer reviews contributions before merging. Opening a pull request does not guarantee
-that a proposed feature will be accepted; project scope and maintenance cost are part of review.
+Respond to review feedback and keep the branch up to date when needed. Maintainers review
+student value, correctness, accessibility, and maintenance cost before merging. A proposed
+feature may need a smaller scope or further discussion.
diff --git a/README.md b/README.md
index b306851..fbaeb32 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,13 @@
# ProCoderJii Learning Hub
-### A focused learning space for engineering students.
+### Practise for campus placements. Understand your answers. Build a study habit.
-Browse course resources, learn from practical guides, explore career opportunities, and take a short break with free coding games.
+A student learning workspace with original practice questions, explanations, and saved progress.
-[Visit the website](https://procoderji.com) · [Report a bug](https://github.com/Coding-Professional/procoderji/issues/new?template=bug_report.yml) · [Request a feature](https://github.com/Coding-Professional/procoderji/issues/new?template=feature_request.yml)
+[Website](https://procoderji.com) · [Documentation](docs/README.md) · [Roadmap](https://github.com/procoderji-com/procoderji/issues/22) · [Report a bug](https://github.com/procoderji-com/procoderji/issues/new?template=bug_report.yml) · [Propose a question](https://github.com/procoderji-com/procoderji/issues/new?template=practice_question.yml)
-[](https://github.com/Coding-Professional/procoderji/actions/workflows/ci.yml)
+[](https://github.com/procoderji-com/procoderji/actions/workflows/ci.yml)
[](LICENSE)
[](https://nextjs.org/)
[](https://www.typescriptlang.org/)
@@ -18,121 +18,179 @@ Browse course resources, learn from practical guides, explore career opportuniti
> [!NOTE]
-> The course browser is in place, but no course subjects or notes are published yet. Guides and job listings are also awaiting reviewed content. The two games are playable now. See [Current content](#current-content) for details.
+> This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in and account progress when configured. Billing and checkout are not enabled. Course notes, guides, and job listings are still awaiting published content.
+
+
+
+See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work.
## Contents
-- [About](#about)
-- [Features](#features)
+- [Student experience](#student-experience)
+- [Progress and optional accounts](#progress-and-optional-accounts)
- [Current content](#current-content)
- [Getting started](#getting-started)
-- [Commands](#commands)
+- [Commands and CI](#commands-and-ci)
- [Project structure](#project-structure)
- [Contributing](#contributing)
-- [Security and privacy](#security-and-privacy)
+- [Privacy and security](#privacy-and-security)
- [License and attributions](#license-and-attributions)
-## About
+## Student experience
-ProCoderJii is a learning hub for engineering students. The project brings course material, practical learning guides, career listings, and small browser games together in one Next.js website. It is built to make useful, reviewed resources easier to browse and to give contributors a clear path for adding them.
+ProCoderJii helps engineering students practise placement skills in short sessions and learn from
+their answers. Run the app locally to explore the implemented routes:
+
+| Route | What it provides |
+| --- | --- |
+| `/learn` | A learner workspace with practice history, topic progress, and a suggested next step. |
+| `/practice` | Quantitative aptitude, logical reasoning, verbal ability, and programming foundations. |
+| `/practice/[topic]` | Multiple-choice practice sessions with scores, correct answers, and explanations. |
+| `/games` | Pattern Sprint and Digit Recall: short reasoning and memory exercises. |
+| `/account` | Optional Google sign-in and cloud progress controls when the backend is configured. |
+| `/dashboard` | Engineering course, branch, and semester navigation for future reviewed notes. |
-The repository is organized for community contributions: start with the guides below, open an issue to discuss a larger change, and submit a pull request when you are ready.
+Questions are original learning exercises. They are independent of employer assessments and do
+not predict or guarantee hiring outcomes. The first question bank is intentionally small so it
+can be reviewed and improved before expanding.
-## Features
+## Progress and optional accounts
-- **Course browser:** organize learning material by course, branch, and semester.
-- **Games:** Pattern Sprint and Digit Recall are free to play at [`/games`](https://procoderji.com/games). No login is required and scores are not saved between visits.
-- **Guides and jobs:** curated sections for practical guides and career opportunities. Only reviewed entries are shown.
-- **Optional video cards:** show recent videos from a public YouTube channel feed when configured.
-- **Content validation:** validate Markdown and MDX content and prevent unsafe HTML from being rendered.
-- **Responsive interface:** browse the site on desktop and mobile.
+Anonymous practice saves the latest **100 completed attempts** in the current browser. The
+learner workspace derives results from answers and the current question bank. Clearing browser
+site data removes that local history, and browser-only progress does not travel between devices.
-There are currently no user accounts, database, admin panel, PDF downloads, or chatbot. The contact form opens the visitor's email app; the site does not store submissions.
+The optional **ASP.NET Core 10** backend provides Google sign-in and account progress through
+the frontend's same-origin API. Account progress is kept separate from guest progress. The
+backend authenticates requests, validates attempts against the shared question bank, and stores
+each student's own history with Entity Framework Core. See [Backend setup](backend/README.md)
+for local persistence, Google credentials, and production integration checks.
+
+The free practice flow works without provider credentials. Paid plans, payment processing, and
+commercial account entitlements require separate product and provider decisions; this repository
+does not currently enable them.
## Current content
-| Section | Status |
+| Area | Status |
| --- | --- |
-| Course browser | Course, branch, and semester navigation is available; no subjects or notes are published yet. |
-| Games | Pattern Sprint and Digit Recall are playable without an account. |
+| Placement practice | 24 questions across four topics, explanations, and saved session history. |
+| Short games | Pattern Sprint and Digit Recall are playable without login; their scores are not saved. |
+| Course catalogue | Course, branch, and semester navigation exists; no subjects or notes are published yet. |
| Guides | No reviewed guides published yet. |
-| Jobs | No reviewed listings published yet. |
-| Videos | Optional; requires a public YouTube channel ID. |
+| Jobs and internships | No reviewed listings published yet. |
+| Latest videos | Optional public YouTube channel feed. |
-Sample files under `src/content` are intentionally not registered as published notes. Please follow the [content guide](docs/content-guide.md) before adding learning material.
+Sample notes under `src/content` are intentionally unregistered. Follow the
+[content guide](docs/content-guide.md) before publishing learning material. The
+[roadmap](https://github.com/procoderji-com/procoderji/issues/22) records longer-term priorities.
## Getting started
-### Requirements
-
-- Node.js 22.18 or newer for the full test suite (Node.js 24 is used in CI).
-- npm.
-- Google Chrome for browser tests. Microsoft Edge can be used with `E2E_CHANNEL=msedge`.
-
-Clone the repository and install its locked dependencies:
+Use Node.js **24**, npm, and Google Chrome for frontend browser checks. `.nvmrc` and
+`.node-version` record the supported Node major. Install the **.NET 10 SDK** when working on the
+backend or enabling account synchronization.
```bash
-git clone https://github.com/Coding-Professional/procoderji.git
+git clone https://github.com/procoderji-com/procoderji.git
cd procoderji
npm ci
npm run dev
```
-Open . The site works without environment variables. To show recent videos locally, copy `.env.example` to `.env.local` and configure the public YouTube channel ID.
+Open . No environment file is needed for practice and local progress.
+External contributors should follow the [fork workflow](CONTRIBUTING.md#set-up-your-fork).
-### Environment
+Copy `.env.example` to `.env.local` only when enabling optional services:
-| Variable | Required | Purpose |
-| --- | --- | --- |
-| `YOUTUBE_CHANNEL_ID` | No | Enables the latest-video section using the channel's public feed. |
-| `NEXT_PUBLIC_SITE_URL` | No | Overrides the canonical site URL, such as for a staging deployment. |
+| Variable | Purpose |
+| --- | --- |
+| `YOUTUBE_CHANNEL_ID` | Public channel ID for latest-video cards. |
+| `NEXT_PUBLIC_SITE_URL` | Canonical site URL override, such as a staging origin. |
+| `DOTNET_API_URL` | Optional server-only backend origin, such as `http://localhost:5100`. |
-See [deployment documentation](docs/deployment.md) for hosting and environment details. Never commit secrets or personal credentials.
+Google credentials belong in the backend's user-secrets or environment configuration, never in
+browser variables. Use the [development guide](docs/development.md) for setup and
+troubleshooting, [backend guide](backend/README.md) for accounts, and
+[deployment guide](docs/deployment.md) for hosting.
-## Commands
+## Commands and CI
| Command | Purpose |
| --- | --- |
| `npm run dev` | Start the local development server. |
| `npm run lint` | Run ESLint. |
| `npm run typecheck` | Check TypeScript types. |
-| `npm test` | Run unit and content validation tests. |
+| `npm test` | Run unit tests, including practice-domain, progress-model/store, and content-validation tests. |
| `npm run build` | Create the production build. |
-| `npm run launch-check` | Build and crawl the site, checking links and SEO metadata. |
-| `npm run e2e` | Run browser checks in Chrome on desktop and mobile. |
+| `npm run e2e` | Build with synthetic content and check frontend browser behavior; opt into the backend with `E2E_DOTNET=1`. |
+| `npm run launch-check` | Build the real site and crawl links, sitemap, metadata, and SEO rules. |
+| `dotnet test backend/ProCoderJi.slnx --configuration Release` | Run backend integration and validation tests. |
+
+| CI job | Coverage |
+| --- | --- |
+| Lint, typecheck, tests and build | TypeScript, code quality, domain/store tests, and the production build. |
+| Browser and SEO checks | Desktop/mobile flows, a real .NET process and frontend proxy, the Google sign-in challenge, and a crawl of links/metadata. |
+| Backend build and tests | The .NET solution build and tests for backend rules and access behavior. |
+
+These jobs need no Google credentials or other external secrets. Browser screenshots are
+retained for seven days when the site-quality job fails. A deployed Google sign-in flow still
+needs the configured-provider checks in [Backend setup](backend/README.md). Browser CI verifies
+the challenge and public callback URL with test client configuration; it does not complete real
+Google consent or token exchange.
-Browser checks use temporary synthetic content and restore their fixtures when they exit. They refuse to run if `src/data` or `src/content` has local changes. The runner and fixtures are documented in [`e2e/run.mjs`](e2e/run.mjs) and the [`e2e/` directory](e2e/).
+The E2E runner refuses to run when `src/data` or `src/content` has local changes because it
+temporarily installs synthetic fixtures. See [Development](docs/development.md#checks).
## Project structure
```text
-src/app/ Routes and page layouts (Next.js App Router)
-src/components/ Shared UI and page sections
-src/content/ Markdown and MDX learning content
-src/data/ Course, guide, and job catalogues
-src/lib/ Content validation, rendering, SEO, and game logic
-e2e/ Browser tests, fixtures, and production crawl checks
-docs/ Architecture, content, security, and deployment guides
-.github/ CI workflow, issue forms, and pull-request template
-.agents/ Optional coding-agent instructions
-.claude/ Pointers to canonical agent-tool documentation
-seo/ Site-specific search and editorial planning notes
+src/app/ Next.js routes, metadata, and backend-for-frontend API
+src/features/practice/ Pure practice rules, question-bank adapter, and practice UI
+src/features/progress/ Validated attempt records and browser progress storage
+src/features/account/ Optional account-provider clients and controls
+src/components/ Shared UI and landing-page sections
+src/content/ Markdown and MDX learning content
+src/data/ Course, guide, and job catalogues
+src/lib/ Existing content, rendering, SEO, and game helpers
+backend/ ASP.NET Core 10 API, EF persistence, and backend tests
+shared/practice/ Canonical question JSON used by TypeScript and .NET
+e2e/ Browser checks, fixtures, and production crawl
+docs/ Development, architecture, content, and decision guides
+.github/ CI, Dependabot, issue forms, and pull-request template
```
-For the system overview, read [Architecture](docs/architecture.md). To add or update published notes, read the [Content guide](docs/content-guide.md).
+The Next.js frontend and .NET backend have clear boundaries in one repository. Read
+[Architecture](docs/architecture.md) and [ADR 0001](docs/decisions/0001-student-product-boundaries.md)
+for the boundaries between learning rules, UI, storage, and providers.
## Contributing
-Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For larger changes, open an issue first so the scope can be discussed. Bug reports and feature ideas can use the repository's issue forms.
+Start with [CONTRIBUTING.md](CONTRIBUTING.md) and the [documentation index](docs/README.md).
+Check open issues before starting a larger feature and describe the student problem it solves.
+Contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
-Contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md). Submit original learning content or link to official sources; do not copy notes, job descriptions, papers, or images without permission.
+| Contribution | Start here |
+| --- | --- |
+| Original questions and explanations | [Question proposal](https://github.com/procoderji-com/procoderji/issues/new?template=practice_question.yml), then the [shared question bank](shared/practice/questions.json). |
+| Scoring, validation, or recommendations | [Practice domain](src/features/practice/domain/) and its tests. |
+| Saved progress behavior | [Progress feature](src/features/progress/) and its model/store tests. |
+| Student interface and accessibility | [Practice views](src/features/practice/client/) and shared components. |
+| Optional accounts, synchronization, or persistence | [Backend setup](backend/README.md), [account feature](src/features/account/), and the Next.js `/api/platform/*` proxy. |
+| Original notes, guides, and listings | [Content guide](docs/content-guide.md). |
+| Setup and contributor guidance | `docs/` and the [contribution guide](CONTRIBUTING.md). |
-## Security and privacy
+Submit original material or link to official sources. Do not copy notes, question banks, job
+descriptions, papers, or images without permission. Optional coding-agent tools and their
+upstream notices are documented in [docs/agent-tools.md](docs/agent-tools.md) and
+[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
-Please report vulnerabilities using the private reporting instructions in [SECURITY.md](SECURITY.md). The site's data handling is described in its [privacy policy](https://procoderji.com/privacy). For local setup and deployment, see [docs/deployment.md](docs/deployment.md).
+## Privacy and security
-## License and attributions
+Read the site's [privacy policy](https://procoderji.com/privacy) for data handling. Report
+vulnerabilities privately using [SECURITY.md](SECURITY.md).
-The project is available under the [MIT License](LICENSE). Optional coding-agent tools and their upstream license notices are documented in [docs/agent-tools.md](docs/agent-tools.md) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+## License and attributions
-The `private` setting in `package.json` prevents publishing this app as an npm package; it does not control GitHub repository visibility.
+The project is available under the [MIT License](LICENSE). Vendored agent tools retain their
+separate upstream notices; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/SECURITY.md b/SECURITY.md
index a13e713..939b20e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -18,3 +18,8 @@ disclosure. There is no bug bounty program at this time.
Run `npm audit` before a release or dependency update. Review advisories and compatible fixes;
avoid force-upgrading or downgrading unrelated packages to silence an audit finding.
+
+For the .NET backend, also run
+`dotnet list backend/ProCoderJi.slnx package --vulnerable --include-transitive` and keep the
+deployed .NET 10 runtime on a current servicing patch. See [security notes](docs/security.md)
+for the current boundaries and known dependency findings.
diff --git a/backend/ProCoderJi.slnx b/backend/ProCoderJi.slnx
new file mode 100644
index 0000000..6711b58
--- /dev/null
+++ b/backend/ProCoderJi.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..5d3893c
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,93 @@
+# ProCoderJii account and progress API
+
+ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, and durable learner progress. The Next.js app forwards `/api/platform/*` to this service through its server-only BFF. Anonymous browser practice stays available when the backend or Google credentials are not configured.
+
+## Run locally
+
+Install a supported .NET 10 SDK and keep its runtime on the latest servicing patch. From the repository root:
+
+```bash
+dotnet restore backend/ProCoderJi.slnx --locked-mode
+dotnet run --project backend/src/ProCoderJi.Api
+```
+
+The development profile listens on `http://localhost:5100`. Set `DOTNET_API_URL=http://localhost:5100` in the frontend's `.env.local` and run Next.js separately. This URL is server configuration and must never use a `NEXT_PUBLIC_` prefix.
+
+SQLite is the default development provider. The API creates and migrates `backend/src/ProCoderJi.Api/Data/procoderji.db` automatically in Development. Users, progress, and ASP.NET Data Protection keys survive process restarts in that database. Generated databases and build output are ignored by Git.
+
+## Google sign-in
+
+Create a Google OAuth **Web application** client and register this exact local authorized redirect URI:
+
+```text
+http://localhost:3000/api/platform/signin-google
+```
+
+The callback belongs to the frontend origin, which forwards it to ASP.NET Core. The backend's `PublicOrigin` configuration defaults to `http://localhost:3000` in the checked-in development settings. It determines OAuth callback URLs, allowed return paths, mutation-origin checks, and cookie security. Set it to the actual HTTPS frontend origin in production.
+
+Keep credentials in .NET user secrets locally:
+
+```bash
+dotnet user-secrets set --project backend/src/ProCoderJi.Api "Authentication:Google:ClientId" "YOUR_CLIENT_ID"
+dotnet user-secrets set --project backend/src/ProCoderJi.Api "Authentication:Google:ClientSecret" "YOUR_CLIENT_SECRET"
+```
+
+Deployment environments can instead supply `Authentication__Google__ClientId` and `Authentication__Google__ClientSecret`. These are backend secrets; never commit them or expose them through frontend environment variables. Without both values, the session endpoint reports `configured: false` and the Google challenge returns 503 while guest practice remains available.
+
+Users are linked by Google's stable provider subject, never by matching email addresses. The app stores its own internal UUID plus the provider subject and current email/display name. Cookies contain the internal account identity; provider access/refresh tokens are not retained. Sessions are HttpOnly, SameSite=Lax, and Secure on HTTPS deployments.
+
+## API contract
+
+| Endpoint | Behavior |
+| --- | --- |
+| `GET /api/platform/auth/session` | Returns `{ configured, user, csrfToken }` and issues the antiforgery cookie. |
+| `GET /api/platform/auth/google?returnTo=/learn` | Begins Google OAuth; return URLs stay under approved learning/account paths on `PublicOrigin`. |
+| `/api/platform/signin-google` | OAuth middleware callback; requires valid state/correlation. |
+| `POST /api/platform/auth/logout` | Clears the session cookie; requires current antiforgery token and same-origin request. |
+| `GET /api/platform/progress` | Returns `{ attempts, generation }` for the authenticated account. |
+| `POST /api/platform/progress` | Accepts `{ attempts, generation }`; validates questions/answers and inserts immutable attempts idempotently. |
+| `DELETE /api/platform/progress` | Deletes the account's attempts and rotates its generation atomically; returns `{ generation }`. |
+
+Progress requests include `x-account-id` matching the signed-in user to reject account-switch races. Mutations also require `Origin` matching `PublicOrigin` and `X-CSRF-Token` obtained from the current session response. A token issued before sign-in/account change must be refreshed. Private responses use `private, no-store`; the BFF preserves cookie and redirect headers.
+
+Attempts store raw question IDs and selected answers, not trusted scores. The backend validates against `shared/practice/questions.json`, which is copied into the API output as `Data/questions.json`. Grading remains reproducible from the canonical bank. This introductory practice is not a proctored or hiring assessment.
+
+Serializable transactions and a state concurrency token serialize sync/reset races. A reset rotates the generation, so old devices cannot silently restore deleted cloud history. Attempts are keyed by account plus session UUID, and the newest 100 are retained per account. Requests are limited to 100 attempts and 256 KiB.
+
+## PostgreSQL and deployment
+
+The same EF Core model and initial migration support PostgreSQL through Npgsql. Supply backend-only configuration:
+
+```text
+Database__Provider=Postgres
+ConnectionStrings__Platform=Host=YOUR_HOST;Database=YOUR_DATABASE;Username=YOUR_USER;Password=YOUR_PASSWORD
+PublicOrigin=https://YOUR_FRONTEND_ORIGIN
+```
+
+Run migrations deliberately before starting production replicas:
+
+```bash
+dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --migrate
+```
+
+This command uses the current environment's database/provider settings and exits after migration. Development and Testing apply migrations automatically; production does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database before schema changes. Keep versioned migrations and the model snapshot together, and validate changes against both providers.
+
+Data Protection keys are stored in the application database with a stable application name, so replicas using the same database can decrypt sessions. Protect database access, backups, and key material at rest, and isolate key rings between deployments. Restrict direct network access to the backend and use the frontend BFF as the public entry point. Configure infrastructure rate limits, HTTPS, operational monitoring, and backups before public account launch.
+
+Razorpay or Paytm integration is planned for a later billing slice. No payment, subscription activation, or live checkout is implemented in this API yet.
+
+## Tests
+
+```bash
+dotnet test backend/ProCoderJi.slnx --no-restore
+```
+
+The default suite creates isolated SQLite databases and uses test-only authentication registered by `WebApplicationFactory` in the test assembly. The API has no test-header login path. Tests cover anonymous behavior, Google challenge URL construction, CSRF/origin checks, account isolation, immutable sync, generation resets, stale requests, question validation, and retention.
+
+To run the same API contract tests against a **disposable PostgreSQL instance**, set `PROCODERJI_TEST_POSTGRES` to its administrative test connection string before running the command. Each factory creates and drops only its own randomly named schema. Do not point test configuration at a production database. A real Google login still needs operator-provided credentials and an end-to-end callback check in the configured deployment.
+
+## References
+
+- [ASP.NET Core Google authentication](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/google-logins?view=aspnetcore-10.0)
+- [ASP.NET Core antiforgery](https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery?view=aspnetcore-10.0)
+- [Data Protection configuration](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-10.0)
diff --git a/backend/src/ProCoderJi.Api/Configuration/PlatformConfiguration.cs b/backend/src/ProCoderJi.Api/Configuration/PlatformConfiguration.cs
new file mode 100644
index 0000000..e162a67
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Configuration/PlatformConfiguration.cs
@@ -0,0 +1,26 @@
+namespace ProCoderJi.Api.Configuration;
+
+public sealed record PlatformConfiguration(Uri PublicOrigin, bool GoogleConfigured)
+{
+ public const string CookieScheme = "PlatformCookie";
+ public const string GoogleScheme = "Google";
+
+ public static PlatformConfiguration From(IConfiguration configuration, IHostEnvironment environment)
+ {
+ var value = configuration["PublicOrigin"] ?? (environment.IsDevelopment() || environment.IsEnvironment("Testing") ? "http://localhost:3000" : null);
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var origin) || origin.UserInfo.Length > 0 || origin.AbsolutePath != "/" || origin.Query.Length > 0 || origin.Fragment.Length > 0 ||
+ (origin.Scheme != "https" && !(origin.Scheme == "http" && origin.IsLoopback && (environment.IsDevelopment() || environment.IsEnvironment("Testing")))))
+ throw new InvalidOperationException("PublicOrigin must be the HTTPS frontend origin, or a loopback HTTP origin for development.");
+ var configured = !string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientId"]) && !string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientSecret"]);
+ return new PlatformConfiguration(origin, configured);
+ }
+
+ public string ReturnUrl(string? value)
+ {
+ var path = "/learn";
+ if (value is not null && value.StartsWith('/') && !value.StartsWith("//") && !value.Contains('\\') && !value.Any(char.IsControl) && Uri.TryCreate(PublicOrigin, value, out var destination) && destination.GetLeftPart(UriPartial.Authority) == PublicOrigin.GetLeftPart(UriPartial.Authority) &&
+ new[] { "/learn", "/practice", "/account" }.Any(root => destination.AbsolutePath == root || destination.AbsolutePath.StartsWith(root + "/", StringComparison.Ordinal)))
+ path = destination.PathAndQuery;
+ return new Uri(PublicOrigin, path).AbsoluteUri;
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
new file mode 100644
index 0000000..7dd3778
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
@@ -0,0 +1,73 @@
+using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace ProCoderJi.Api.Data;
+
+public sealed class PlatformDbContext(DbContextOptions options) : DbContext(options), IDataProtectionKeyContext
+{
+ public DbSet Users => Set();
+ public DbSet ProgressStates => Set();
+ public DbSet Attempts => Set();
+ public DbSet DataProtectionKeys { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.Entity(entity =>
+ {
+ entity.HasKey(user => user.Id);
+ entity.Property(user => user.GoogleSubject).HasMaxLength(255);
+ entity.HasIndex(user => user.GoogleSubject).IsUnique();
+ entity.Property(user => user.Email).HasMaxLength(320);
+ entity.Property(user => user.Name).HasMaxLength(200);
+ });
+ model.Entity(entity =>
+ {
+ entity.HasKey(state => state.UserId);
+ entity.Property(state => state.Revision).IsConcurrencyToken();
+ entity.HasOne().WithOne().HasForeignKey(state => state.UserId).OnDelete(DeleteBehavior.Cascade);
+ });
+ model.Entity(entity =>
+ {
+ entity.HasKey(attempt => new { attempt.UserId, attempt.Id });
+ entity.HasIndex(attempt => new { attempt.UserId, attempt.CompletedAtUtc });
+ entity.Property(attempt => attempt.TopicId).HasMaxLength(32);
+ entity.Property(attempt => attempt.QuestionIdsJson).HasMaxLength(4096);
+ entity.Property(attempt => attempt.ResponsesJson).HasMaxLength(8192);
+ entity.HasOne().WithMany().HasForeignKey(attempt => attempt.UserId).OnDelete(DeleteBehavior.Cascade);
+ entity.ToTable(table =>
+ {
+ table.HasCheckConstraint("CK_Attempt_Duration", "\"DurationSeconds\" >= 0 AND \"DurationSeconds\" <= 7200");
+ table.HasCheckConstraint("CK_Attempt_Topic", "\"TopicId\" IN ('quantitative', 'reasoning', 'verbal', 'programming')");
+ table.HasCheckConstraint("CK_Attempt_Payload", "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
+ });
+ });
+ }
+}
+
+public sealed class LearnerUser
+{
+ public Guid Id { get; set; }
+ public required string GoogleSubject { get; set; }
+ public required string Email { get; set; }
+ public required string Name { get; set; }
+ public long CreatedAtUtc { get; set; }
+}
+
+public sealed class ProgressState
+{
+ public Guid UserId { get; set; }
+ public Guid Generation { get; set; }
+ public Guid Revision { get; set; }
+}
+
+public sealed class PracticeAttemptEntity
+{
+ public Guid UserId { get; set; }
+ public Guid Id { get; set; }
+ public Guid Generation { get; set; }
+ public required string TopicId { get; set; }
+ public required string QuestionIdsJson { get; set; }
+ public required string ResponsesJson { get; set; }
+ public int DurationSeconds { get; set; }
+ public long CompletedAtUtc { get; set; }
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Accounts/AccountEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Accounts/AccountEndpoints.cs
new file mode 100644
index 0000000..3121f3b
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Accounts/AccountEndpoints.cs
@@ -0,0 +1,33 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Authentication;
+using ProCoderJi.Api.Configuration;
+using ProCoderJi.Api.Infrastructure;
+
+namespace ProCoderJi.Api.Features.Accounts;
+
+public static class AccountEndpoints
+{
+ public static void MapAccountEndpoints(this WebApplication app)
+ {
+ app.MapGet("/api/platform/auth/session", (HttpContext context, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
+ {
+ var id = ApiSecurity.UserId(context);
+ var user = id is null ? null : new { id = id.Value.ToString(), email = context.User.FindFirstValue(ClaimTypes.Email) ?? "", name = context.User.FindFirstValue(ClaimTypes.Name) ?? "Student" };
+ var tokens = antiforgery.GetAndStoreTokens(context);
+ return Results.Json(new { configured = configuration.GoogleConfigured, user, csrfToken = tokens.RequestToken ?? "" });
+ });
+ app.MapGet("/api/platform/auth/google", (HttpContext context, PlatformConfiguration configuration, string? returnTo) =>
+ {
+ if (!configuration.GoogleConfigured) return Results.Json(new { error = "Google sign-in is not configured on this deployment." }, statusCode: 503);
+ return Results.Challenge(new AuthenticationProperties { RedirectUri = configuration.ReturnUrl(returnTo) }, [PlatformConfiguration.GoogleScheme]);
+ });
+ app.MapPost("/api/platform/auth/logout", async (HttpContext context, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
+ {
+ var error = await ApiSecurity.CheckWrite(context, configuration, antiforgery);
+ if (error is not null) return error;
+ await context.SignOutAsync(PlatformConfiguration.CookieScheme);
+ return Results.NoContent();
+ });
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs b/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs
new file mode 100644
index 0000000..771b601
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs
@@ -0,0 +1,45 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using ProCoderJi.Api.Configuration;
+using ProCoderJi.Api.Data;
+
+namespace ProCoderJi.Api.Features.Accounts;
+
+public static class GoogleAccountEvents
+{
+ public static async Task CompleteTicket(TicketReceivedContext context)
+ {
+ var subject = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(subject) || subject.Length > 255) { context.Fail("Google did not return a valid subject."); return; }
+ var email = context.Principal?.FindFirstValue(ClaimTypes.Email) ?? "";
+ var name = context.Principal?.FindFirstValue(ClaimTypes.Name) ?? "Student";
+ await using var db = await context.HttpContext.RequestServices.GetRequiredService>().CreateDbContextAsync(context.HttpContext.RequestAborted);
+ var user = await db.Users.SingleOrDefaultAsync(user => user.GoogleSubject == subject, context.HttpContext.RequestAborted);
+ if (user is null)
+ {
+ user = new LearnerUser { Id = Guid.NewGuid(), GoogleSubject = subject, Email = email[..Math.Min(email.Length, 320)], Name = name[..Math.Min(name.Length, 200)], CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
+ db.Users.Add(user);
+ try { await db.SaveChangesAsync(context.HttpContext.RequestAborted); }
+ catch (DbUpdateException)
+ {
+ db.ChangeTracker.Clear();
+ user = await db.Users.SingleOrDefaultAsync(user => user.GoogleSubject == subject, context.HttpContext.RequestAborted);
+ if (user is null) throw;
+ }
+ }
+ var currentEmail = email[..Math.Min(email.Length, 320)];
+ var currentName = name[..Math.Min(name.Length, 200)];
+ if (user.Email != currentEmail || user.Name != currentName)
+ {
+ user.Email = currentEmail;
+ user.Name = currentName;
+ await db.SaveChangesAsync(context.HttpContext.RequestAborted);
+ }
+ var identity = new ClaimsIdentity(PlatformConfiguration.CookieScheme);
+ identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
+ identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
+ identity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
+ context.Principal = new ClaimsPrincipal(identity);
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Practice/PracticeQuestionBank.cs b/backend/src/ProCoderJi.Api/Features/Practice/PracticeQuestionBank.cs
new file mode 100644
index 0000000..feaf937
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Practice/PracticeQuestionBank.cs
@@ -0,0 +1,102 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+
+namespace ProCoderJi.Api.Features.Practice;
+
+/// Loads the same reviewed question definitions used by the frontend.
+public sealed class PracticeQuestionBank
+{
+ public const int MaxSessionQuestions = 30;
+ public const int MaxDurationSeconds = 7_200;
+
+ private static readonly HashSet Topics = ["quantitative", "reasoning", "verbal", "programming"];
+ private static readonly HashSet OptionIds = ["a", "b", "c", "d"];
+ private static readonly Regex StableId = new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.CultureInvariant);
+ private static readonly Regex Uuid = new("^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", RegexOptions.CultureInvariant);
+ private static readonly JsonSerializerOptions QuestionJson = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
+ };
+ private readonly Dictionary questions;
+
+ public PracticeQuestionBank(IWebHostEnvironment environment)
+ : this(File.ReadAllText(ResolvePath(environment))) { }
+
+ private PracticeQuestionBank(string json)
+ {
+ List? definitions;
+ try { definitions = JsonSerializer.Deserialize>(json, QuestionJson); }
+ catch (JsonException cause) { throw new InvalidDataException("The shared practice question bank is not valid JSON.", cause); }
+ if (definitions is null || definitions.Count == 0) throw new InvalidDataException("The shared practice question bank is empty.");
+ questions = new Dictionary(StringComparer.Ordinal);
+ foreach (var question in definitions)
+ {
+ if (question is null || string.IsNullOrWhiteSpace(question.Id) || !StableId.IsMatch(question.Id)
+ || !Topics.Contains(question.TopicId ?? string.Empty)
+ || question.Difficulty is not ("easy" or "medium")
+ || string.IsNullOrWhiteSpace(question.Prompt) || string.IsNullOrWhiteSpace(question.Explanation)
+ || question.Options is null || question.Options.Count != 4
+ || question.Options.Any(option => option is null || !OptionIds.Contains(option.Id ?? string.Empty) || string.IsNullOrWhiteSpace(option.Text))
+ || question.Options.Select(option => option.Id).Distinct(StringComparer.Ordinal).Count() != 4
+ || question.Options.Select(option => option.Text.Trim()).Distinct(StringComparer.Ordinal).Count() != 4
+ || question.Options.Count(option => option.Id == question.AnswerId) != 1)
+ throw new InvalidDataException("A shared practice question has invalid fields or answer options.");
+ if (!questions.TryAdd(question.Id, question)) throw new InvalidDataException($"Duplicate shared practice question ID: {question.Id}");
+ }
+ if (Topics.Any(topic => !questions.Values.Any(question => question.TopicId == topic)))
+ throw new InvalidDataException("Every practice topic must contain a reviewed question.");
+ }
+
+ public int QuestionCount => questions.Count;
+
+ /// Allows deterministic validation tests without a web host or database.
+ public static PracticeQuestionBank FromJson(string json) => new(json);
+
+ public bool TryValidate(ProgressAttemptDto? attempt, DateTimeOffset now, out ValidatedAttempt? valid)
+ {
+ valid = null;
+ if (attempt is null) return false;
+ var topicId = attempt.TopicId;
+ if (!Uuid.IsMatch(attempt.Id.ToString("D"))
+ || string.IsNullOrEmpty(attempt.CompletedAt)
+ || !DateTimeOffset.TryParseExact(attempt.CompletedAt, "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var completedAt)
+ || completedAt <= DateTimeOffset.UnixEpoch || completedAt > now.AddMinutes(5)
+ || string.IsNullOrEmpty(topicId) || !Topics.Contains(topicId)
+ || attempt.DurationSeconds is < 0 or > MaxDurationSeconds
+ || attempt.QuestionIds is null || attempt.QuestionIds.Count is < 1 or > MaxSessionQuestions
+ || attempt.Responses is null || attempt.Responses.Count > attempt.QuestionIds.Count)
+ return false;
+
+ var selectedQuestions = new HashSet(StringComparer.Ordinal);
+ foreach (var id in attempt.QuestionIds)
+ {
+ if (string.IsNullOrEmpty(id) || !selectedQuestions.Add(id)
+ || !questions.TryGetValue(id, out var question) || question.TopicId != topicId)
+ return false;
+ }
+ var answered = new HashSet(StringComparer.Ordinal);
+ foreach (var response in attempt.Responses)
+ {
+ if (response is null || string.IsNullOrEmpty(response.QuestionId) || !selectedQuestions.Contains(response.QuestionId)
+ || !answered.Add(response.QuestionId)
+ || !questions[response.QuestionId].Options!.Any(option => option.Id == response.OptionId))
+ return false;
+ }
+ valid = new ValidatedAttempt(attempt.Id, completedAt, topicId, [.. attempt.QuestionIds], [.. attempt.Responses], attempt.DurationSeconds);
+ return true;
+ }
+
+ private static string ResolvePath(IWebHostEnvironment environment)
+ {
+ var outputPath = Path.Combine(AppContext.BaseDirectory, "Data", "questions.json");
+ return File.Exists(outputPath) ? outputPath : Path.Combine(environment.ContentRootPath, "Data", "questions.json");
+ }
+
+ private sealed record QuestionDefinition(string Id, string TopicId, string Difficulty, string Prompt,
+ List? Options, string AnswerId, string Explanation);
+ private sealed record OptionDefinition(string Id, string Text);
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Practice/ProgressAttemptDto.cs b/backend/src/ProCoderJi.Api/Features/Practice/ProgressAttemptDto.cs
new file mode 100644
index 0000000..91abeb4
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Practice/ProgressAttemptDto.cs
@@ -0,0 +1,36 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace ProCoderJi.Api.Features.Practice;
+
+/// Raw responses are accepted; clients cannot provide trusted scores.
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record ProgressAttemptDto(
+ [property: JsonRequired, JsonPropertyName("id")] Guid Id,
+ [property: JsonRequired, JsonPropertyName("completedAt")] string CompletedAt,
+ [property: JsonRequired, JsonPropertyName("topicId")] string TopicId,
+ [property: JsonRequired, JsonPropertyName("questionIds")] List? QuestionIds,
+ [property: JsonRequired, JsonPropertyName("responses")] List? Responses,
+ [property: JsonRequired, JsonPropertyName("durationSeconds")] int DurationSeconds);
+
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record PracticeResponseDto(
+ [property: JsonRequired, JsonPropertyName("questionId")] string QuestionId,
+ [property: JsonRequired, JsonPropertyName("optionId")] string OptionId);
+
+public sealed record ValidatedAttempt(
+ Guid Id,
+ DateTimeOffset CompletedAt,
+ string TopicId,
+ string[] QuestionIds,
+ PracticeResponseDto[] Responses,
+ int DurationSeconds)
+{
+ public ProgressAttemptDto ToDto() => new(
+ Id,
+ CompletedAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture),
+ TopicId,
+ [.. QuestionIds],
+ [.. Responses],
+ DurationSeconds);
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs
new file mode 100644
index 0000000..f055997
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs
@@ -0,0 +1,74 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Antiforgery;
+using ProCoderJi.Api.Configuration;
+using ProCoderJi.Api.Features.Practice;
+using ProCoderJi.Api.Infrastructure;
+
+namespace ProCoderJi.Api.Features.Progress;
+
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record SyncProgressRequest([property: JsonRequired] List? Attempts, [property: JsonRequired] Guid Generation);
+
+public static class ProgressEndpoints
+{
+ private const int MaxBytes = 256 * 1024;
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
+
+ public static void MapProgressEndpoints(this WebApplication app)
+ {
+ app.MapGet("/api/platform/progress", async (HttpContext context, ProgressService service) =>
+ {
+ var userId = ApiSecurity.UserId(context);
+ if (userId is null) return Results.Json(new { error = "Sign in to sync your progress." }, statusCode: 401);
+ var accountError = ApiSecurity.CheckAccount(context, userId.Value);
+ if (accountError is not null) return accountError;
+ return Results.Json(await service.Read(userId.Value, context.RequestAborted));
+ });
+ app.MapPost("/api/platform/progress", async (HttpContext context, ProgressService service, PracticeQuestionBank bank, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
+ {
+ var userId = ApiSecurity.UserId(context);
+ if (userId is null) return Results.Json(new { error = "Sign in to sync your progress." }, statusCode: 401);
+ var accountError = ApiSecurity.CheckAccount(context, userId.Value);
+ if (accountError is not null) return accountError;
+ var securityError = await ApiSecurity.CheckWrite(context, configuration, antiforgery);
+ if (securityError is not null) return securityError;
+ if (!context.Request.HasJsonContentType()) return Results.Json(new { error = "Send a JSON progress payload." }, statusCode: 415);
+ if (context.Request.ContentLength > MaxBytes) return Results.Json(new { error = "Progress payload is too large." }, statusCode: 413);
+ SyncProgressRequest? body;
+ try
+ {
+ using var buffer = new MemoryStream();
+ var chunk = new byte[8192];
+ int count;
+ while ((count = await context.Request.Body.ReadAsync(chunk, context.RequestAborted)) > 0)
+ {
+ if (buffer.Length + count > MaxBytes) return Results.Json(new { error = "Progress payload is too large." }, statusCode: 413);
+ await buffer.WriteAsync(chunk.AsMemory(0, count), context.RequestAborted);
+ }
+ body = JsonSerializer.Deserialize(buffer.ToArray(), Json);
+ }
+ catch (JsonException) { return Results.Json(new { error = "Send valid attempts and a progress generation." }, statusCode: 400); }
+ if (body?.Attempts is null || body.Attempts.Count > 100 || body.Generation == Guid.Empty || body.Attempts.Select(attempt => attempt?.Id).Distinct().Count() != body.Attempts.Count)
+ return Results.Json(new { error = "Send at most 100 unique attempts and a valid generation." }, statusCode: 400);
+ var attempts = new List();
+ foreach (var attempt in body.Attempts)
+ {
+ if (!bank.TryValidate(attempt, DateTimeOffset.UtcNow, out var valid) || valid is null) return Results.Json(new { error = "Some practice attempts are invalid." }, statusCode: 400);
+ attempts.Add(valid);
+ }
+ try { return Results.Json(await service.Sync(userId.Value, body.Generation, attempts, context.RequestAborted)); }
+ catch (ProgressGenerationConflictException) { return Results.Json(new { error = "Progress was reset on another device. Refresh before syncing." }, statusCode: 409); }
+ });
+ app.MapDelete("/api/platform/progress", async (HttpContext context, ProgressService service, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
+ {
+ var userId = ApiSecurity.UserId(context);
+ if (userId is null) return Results.Json(new { error = "Sign in to sync your progress." }, statusCode: 401);
+ var accountError = ApiSecurity.CheckAccount(context, userId.Value);
+ if (accountError is not null) return accountError;
+ var securityError = await ApiSecurity.CheckWrite(context, configuration, antiforgery);
+ if (securityError is not null) return securityError;
+ return Results.Json(new { generation = await service.Reset(userId.Value, context.RequestAborted) });
+ });
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs b/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs
new file mode 100644
index 0000000..56d332d
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs
@@ -0,0 +1,98 @@
+using System.Data;
+using System.Text.Json;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using ProCoderJi.Api.Data;
+using ProCoderJi.Api.Features.Practice;
+
+namespace ProCoderJi.Api.Features.Progress;
+
+public sealed record ProgressSnapshot(IReadOnlyList Attempts, Guid Generation);
+public sealed class ProgressGenerationConflictException : Exception { }
+
+public sealed class ProgressService(IDbContextFactory factory)
+{
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ public Task Read(Guid userId, CancellationToken cancellationToken) => InTransaction(async db =>
+ {
+ var state = await State(db, userId, cancellationToken);
+ return await Snapshot(db, state, cancellationToken);
+ }, cancellationToken);
+
+ public Task Sync(Guid userId, Guid generation, IReadOnlyList attempts, CancellationToken cancellationToken) => InTransaction(async db =>
+ {
+ var state = await State(db, userId, cancellationToken);
+ if (state.Generation != generation) throw new ProgressGenerationConflictException();
+ var ids = attempts.Select(attempt => attempt.Id).ToArray();
+ var existing = await db.Attempts.Where(attempt => attempt.UserId == userId && ids.Contains(attempt.Id)).Select(attempt => attempt.Id).ToListAsync(cancellationToken);
+ foreach (var attempt in attempts.Where(attempt => !existing.Contains(attempt.Id)))
+ db.Attempts.Add(new PracticeAttemptEntity
+ {
+ Id = attempt.Id, UserId = userId, Generation = generation, TopicId = attempt.TopicId,
+ QuestionIdsJson = JsonSerializer.Serialize(attempt.QuestionIds, Json), ResponsesJson = JsonSerializer.Serialize(attempt.Responses, Json),
+ CompletedAtUtc = attempt.CompletedAt.ToUnixTimeMilliseconds(), DurationSeconds = attempt.DurationSeconds,
+ });
+ state.Revision = Guid.NewGuid();
+ await db.SaveChangesAsync(cancellationToken);
+ var old = await db.Attempts.Where(attempt => attempt.UserId == userId).OrderByDescending(attempt => attempt.CompletedAtUtc).ThenByDescending(attempt => attempt.Id).Skip(100).ToListAsync(cancellationToken);
+ db.Attempts.RemoveRange(old);
+ await db.SaveChangesAsync(cancellationToken);
+ return await Snapshot(db, state, cancellationToken);
+ }, cancellationToken);
+
+ public Task Reset(Guid userId, CancellationToken cancellationToken) => InTransaction(async db =>
+ {
+ var state = await State(db, userId, cancellationToken);
+ var attempts = await db.Attempts.Where(attempt => attempt.UserId == userId).ToListAsync(cancellationToken);
+ db.Attempts.RemoveRange(attempts);
+ state.Generation = Guid.NewGuid();
+ state.Revision = Guid.NewGuid();
+ await db.SaveChangesAsync(cancellationToken);
+ return state.Generation;
+ }, cancellationToken);
+
+ private static async Task State(PlatformDbContext db, Guid userId, CancellationToken cancellationToken)
+ {
+ var state = await db.ProgressStates.SingleOrDefaultAsync(state => state.UserId == userId, cancellationToken);
+ if (state is not null) return state;
+ state = new ProgressState { UserId = userId, Generation = Guid.NewGuid(), Revision = Guid.NewGuid() };
+ db.ProgressStates.Add(state);
+ await db.SaveChangesAsync(cancellationToken);
+ return state;
+ }
+
+ private static async Task Snapshot(PlatformDbContext db, ProgressState state, CancellationToken cancellationToken)
+ {
+ var rows = await db.Attempts.AsNoTracking().Where(attempt => attempt.UserId == state.UserId && attempt.Generation == state.Generation)
+ .OrderByDescending(attempt => attempt.CompletedAtUtc).ThenByDescending(attempt => attempt.Id).Take(100).ToListAsync(cancellationToken);
+ var attempts = rows.Select(row => new ValidatedAttempt(row.Id, DateTimeOffset.FromUnixTimeMilliseconds(row.CompletedAtUtc), row.TopicId,
+ JsonSerializer.Deserialize(row.QuestionIdsJson, Json) ?? [], JsonSerializer.Deserialize(row.ResponsesJson, Json) ?? [], row.DurationSeconds).ToDto()).ToArray();
+ return new ProgressSnapshot(attempts, state.Generation);
+ }
+
+ private async Task InTransaction(Func> operation, CancellationToken cancellationToken)
+ {
+ for (var retry = 0; ; retry++)
+ {
+ await using var db = await factory.CreateDbContextAsync(cancellationToken);
+ try
+ {
+ await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
+ var result = await operation(db);
+ await transaction.CommitAsync(cancellationToken);
+ return result;
+ }
+ catch (Exception error) when (retry < 3 && IsRetryable(error))
+ {
+ await Task.Delay(25 * (retry + 1), cancellationToken);
+ }
+ }
+ }
+
+ private static bool IsRetryable(Exception error) => error is DbUpdateConcurrencyException
+ || error is SqliteException { SqliteErrorCode: 5 or 6 }
+ || error is PostgresException { SqlState: "40001" or "40P01" or "23505" }
+ || error is DbUpdateException { InnerException: not null } update && IsRetryable(update.InnerException!);
+}
diff --git a/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs b/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs
new file mode 100644
index 0000000..91c1b00
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs
@@ -0,0 +1,23 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Antiforgery;
+using ProCoderJi.Api.Configuration;
+
+namespace ProCoderJi.Api.Infrastructure;
+
+public static class ApiSecurity
+{
+ public static Guid? UserId(HttpContext context) => Guid.TryParse(context.User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) && id != Guid.Empty ? id : null;
+
+ public static IResult? CheckAccount(HttpContext context, Guid id)
+ => !Guid.TryParse(context.Request.Headers["x-account-id"], out var expected) || expected != id
+ ? Results.Json(new { error = "The signed-in account changed. Refresh before syncing progress." }, statusCode: 409) : null;
+
+ public static async Task CheckWrite(HttpContext context, PlatformConfiguration configuration, IAntiforgery antiforgery)
+ {
+ if (!string.Equals(context.Request.Headers.Origin, configuration.PublicOrigin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
+ return Results.Json(new { error = "This request must come from the current site." }, statusCode: 403);
+ try { await antiforgery.ValidateRequestAsync(context); }
+ catch (AntiforgeryValidationException) { return Results.Json(new { error = "Refresh your account session before trying this action." }, statusCode: 403); }
+ return null;
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs b/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs
new file mode 100644
index 0000000..3e981f0
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs
@@ -0,0 +1,53 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using ProCoderJi.Api.Data;
+
+namespace ProCoderJi.Api.Migrations;
+
+[DbContext(typeof(PlatformDbContext))]
+[Migration("202611100001_InitialPlatform")]
+public sealed class InitialPlatform : Migration
+{
+ protected override void Up(MigrationBuilder migration)
+ {
+ migration.CreateTable("Users", columns: table => new
+ {
+ Id = table.Column(nullable: false), GoogleSubject = table.Column(maxLength: 255, nullable: false),
+ Email = table.Column(maxLength: 320, nullable: false), Name = table.Column(maxLength: 200, nullable: false), CreatedAtUtc = table.Column(nullable: false),
+ }, constraints: table => table.PrimaryKey("PK_Users", user => user.Id));
+ migration.CreateIndex("IX_Users_GoogleSubject", "Users", "GoogleSubject", unique: true);
+ migration.CreateTable("DataProtectionKeys", columns: table => new
+ {
+ Id = table.Column(nullable: false).Annotation("Sqlite:Autoincrement", true).Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ FriendlyName = table.Column(nullable: true), Xml = table.Column(nullable: true),
+ }, constraints: table => table.PrimaryKey("PK_DataProtectionKeys", key => key.Id));
+ migration.CreateTable("ProgressStates", columns: table => new
+ {
+ UserId = table.Column(nullable: false), Generation = table.Column(nullable: false), Revision = table.Column(nullable: false),
+ }, constraints: table =>
+ {
+ table.PrimaryKey("PK_ProgressStates", state => state.UserId);
+ table.ForeignKey("FK_ProgressStates_Users_UserId", state => state.UserId, "Users", "Id", onDelete: ReferentialAction.Cascade);
+ });
+ migration.CreateTable("Attempts", columns: table => new
+ {
+ UserId = table.Column(nullable: false), Id = table.Column(nullable: false), Generation = table.Column(nullable: false),
+ TopicId = table.Column(maxLength: 32, nullable: false), QuestionIdsJson = table.Column(maxLength: 4096, nullable: false), ResponsesJson = table.Column(maxLength: 8192, nullable: false),
+ DurationSeconds = table.Column(nullable: false), CompletedAtUtc = table.Column(nullable: false),
+ }, constraints: table =>
+ {
+ table.PrimaryKey("PK_Attempts", attempt => new { attempt.UserId, attempt.Id });
+ table.ForeignKey("FK_Attempts_Users_UserId", attempt => attempt.UserId, "Users", "Id", onDelete: ReferentialAction.Cascade);
+ table.CheckConstraint("CK_Attempt_Duration", "\"DurationSeconds\" >= 0 AND \"DurationSeconds\" <= 7200");
+ table.CheckConstraint("CK_Attempt_Topic", "\"TopicId\" IN ('quantitative', 'reasoning', 'verbal', 'programming')");
+ table.CheckConstraint("CK_Attempt_Payload", "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
+ });
+ migration.CreateIndex("IX_Attempts_UserId_CompletedAtUtc", "Attempts", ["UserId", "CompletedAtUtc"]);
+ }
+
+ protected override void Down(MigrationBuilder migration)
+ {
+ migration.DropTable("Attempts"); migration.DropTable("ProgressStates"); migration.DropTable("DataProtectionKeys"); migration.DropTable("Users");
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs
new file mode 100644
index 0000000..372c869
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs
@@ -0,0 +1,64 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using ProCoderJi.Api.Data;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+namespace ProCoderJi.Api.Migrations;
+
+[DbContext(typeof(PlatformDbContext))]
+public sealed class PlatformDbContextModelSnapshot : ModelSnapshot
+{
+ protected override void BuildModel(ModelBuilder model)
+ {
+ model.HasAnnotation("ProductVersion", "10.0.14");
+ model.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+ model.Entity("ProCoderJi.Api.Data.LearnerUser", entity =>
+ {
+ entity.Property("Id").ValueGeneratedOnAdd();
+ entity.Property("GoogleSubject").IsRequired().HasMaxLength(255);
+ entity.Property("Email").IsRequired().HasMaxLength(320);
+ entity.Property("Name").IsRequired().HasMaxLength(200);
+ entity.Property("CreatedAtUtc");
+ entity.HasKey("Id");
+ entity.HasIndex("GoogleSubject").IsUnique();
+ entity.ToTable("Users");
+ });
+ model.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", entity =>
+ {
+ entity.Property("Id").ValueGeneratedOnAdd().HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+ entity.Property("FriendlyName");
+ entity.Property("Xml");
+ entity.HasKey("Id");
+ entity.ToTable("DataProtectionKeys");
+ });
+ model.Entity("ProCoderJi.Api.Data.ProgressState", entity =>
+ {
+ entity.Property("UserId");
+ entity.Property("Generation");
+ entity.Property("Revision").IsConcurrencyToken();
+ entity.HasKey("UserId");
+ entity.ToTable("ProgressStates");
+ entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithOne().HasForeignKey("ProCoderJi.Api.Data.ProgressState", "UserId").OnDelete(DeleteBehavior.Cascade).IsRequired();
+ });
+ model.Entity("ProCoderJi.Api.Data.PracticeAttemptEntity", entity =>
+ {
+ entity.Property("UserId");
+ entity.Property("Id");
+ entity.Property("Generation");
+ entity.Property("TopicId").IsRequired().HasMaxLength(32);
+ entity.Property("QuestionIdsJson").IsRequired().HasMaxLength(4096);
+ entity.Property("ResponsesJson").IsRequired().HasMaxLength(8192);
+ entity.Property("DurationSeconds");
+ entity.Property("CompletedAtUtc");
+ entity.HasKey("UserId", "Id");
+ entity.HasIndex("UserId", "CompletedAtUtc");
+ entity.ToTable("Attempts", table =>
+ {
+ table.HasCheckConstraint("CK_Attempt_Duration", "\"DurationSeconds\" >= 0 AND \"DurationSeconds\" <= 7200");
+ table.HasCheckConstraint("CK_Attempt_Topic", "\"TopicId\" IN ('quantitative', 'reasoning', 'verbal', 'programming')");
+ table.HasCheckConstraint("CK_Attempt_Payload", "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
+ });
+ entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithMany().HasForeignKey("UserId").OnDelete(DeleteBehavior.Cascade).IsRequired();
+ });
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
new file mode 100644
index 0000000..e254ada
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ procoderji-platform-api-development
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/ProCoderJi.Api/Program.cs b/backend/src/ProCoderJi.Api/Program.cs
new file mode 100644
index 0000000..878affe
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Program.cs
@@ -0,0 +1,131 @@
+using System.Security.Claims;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using ProCoderJi.Api.Configuration;
+using ProCoderJi.Api.Data;
+using ProCoderJi.Api.Features.Accounts;
+using ProCoderJi.Api.Features.Practice;
+using ProCoderJi.Api.Features.Progress;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddSingleton(services => PlatformConfiguration.From(services.GetRequiredService(), services.GetRequiredService()));
+builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
+builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 256 * 1024);
+
+builder.Services.AddDbContextFactory((services, options) =>
+{
+ var configuration = services.GetRequiredService();
+ var provider = configuration["Database:Provider"] ?? "Sqlite";
+ var connection = configuration.GetConnectionString("Platform");
+ if (provider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase))
+ {
+ var sqlite = new SqliteConnectionStringBuilder(connection ?? "Data Source=Data/procoderji.db");
+ if (sqlite.DataSource != ":memory:" && !Path.IsPathRooted(sqlite.DataSource)) sqlite.DataSource = Path.Combine(services.GetRequiredService().ContentRootPath, sqlite.DataSource);
+ if (sqlite.DataSource != ":memory:") Directory.CreateDirectory(Path.GetDirectoryName(sqlite.DataSource)!);
+ options.UseSqlite(sqlite.ConnectionString);
+ }
+ else if (provider.Equals("Postgres", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(connection)) options.UseNpgsql(connection);
+ else throw new InvalidOperationException("Choose Sqlite or configure Postgres with ConnectionStrings:Platform.");
+});
+builder.Services.AddDataProtection().SetApplicationName("ProCoderJi.Platform").PersistKeysToDbContext();
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+
+var authentication = builder.Services.AddAuthentication(options =>
+{
+ options.DefaultAuthenticateScheme = PlatformConfiguration.CookieScheme;
+ options.DefaultSignInScheme = PlatformConfiguration.CookieScheme;
+ options.DefaultChallengeScheme = PlatformConfiguration.CookieScheme;
+}).AddCookie(PlatformConfiguration.CookieScheme, options =>
+{
+ var platform = PlatformConfiguration.From(builder.Configuration, builder.Environment);
+ options.Cookie.Name = "procoderji.session";
+ options.Cookie.Path = "/api/platform";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SameSite = SameSiteMode.Lax;
+ options.Cookie.SecurePolicy = platform.PublicOrigin.Scheme == "https" ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
+ options.ExpireTimeSpan = TimeSpan.FromDays(7);
+ options.SlidingExpiration = true;
+ options.Events = new CookieAuthenticationEvents
+ {
+ OnRedirectToLogin = context => { context.Response.StatusCode = 401; return Task.CompletedTask; },
+ OnRedirectToAccessDenied = context => { context.Response.StatusCode = 403; return Task.CompletedTask; },
+ OnValidatePrincipal = async context =>
+ {
+ if (!Guid.TryParse(context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id)) { context.RejectPrincipal(); return; }
+ await using var db = await context.HttpContext.RequestServices.GetRequiredService>().CreateDbContextAsync(context.HttpContext.RequestAborted);
+ if (!await db.Users.AnyAsync(user => user.Id == id, context.HttpContext.RequestAborted)) context.RejectPrincipal();
+ },
+ };
+});
+authentication.AddGoogle(PlatformConfiguration.GoogleScheme, options =>
+ {
+ var platform = PlatformConfiguration.From(builder.Configuration, builder.Environment);
+ // The challenge endpoint returns 503 when absent. Nonempty placeholders
+ // let middleware initialize without requiring credentials for guest use.
+ options.ClientId = builder.Configuration["Authentication:Google:ClientId"] is { Length: > 0 } id ? id : "disabled";
+ options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] is { Length: > 0 } secret ? secret : "disabled";
+ options.CallbackPath = "/api/platform/signin-google";
+ options.SignInScheme = PlatformConfiguration.CookieScheme;
+ options.SaveTokens = false;
+ options.CorrelationCookie.Path = "/api/platform";
+ options.CorrelationCookie.HttpOnly = true;
+ options.CorrelationCookie.SameSite = SameSiteMode.Lax;
+ options.CorrelationCookie.SecurePolicy = platform.PublicOrigin.Scheme == "https" ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
+ options.Events.OnTicketReceived = GoogleAccountEvents.CompleteTicket;
+ options.Events.OnRemoteFailure = context =>
+ {
+ context.Response.Redirect(new Uri(platform.PublicOrigin, "/account?error=auth").AbsoluteUri);
+ context.HandleResponse();
+ return Task.CompletedTask;
+ };
+ });
+builder.Services.AddAuthorization();
+builder.Services.AddAntiforgery(options =>
+{
+ var platform = PlatformConfiguration.From(builder.Configuration, builder.Environment);
+ options.HeaderName = "X-CSRF-Token";
+ options.Cookie.Name = "procoderji.antiforgery";
+ options.Cookie.Path = "/api/platform";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SameSite = SameSiteMode.Strict;
+ options.Cookie.SecurePolicy = platform.PublicOrigin.Scheme == "https" ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
+});
+
+var app = builder.Build();
+var platform = app.Services.GetRequiredService();
+if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || builder.Configuration.GetValue("Database:MigrateOnStartup") || args.Contains("--migrate"))
+{
+ await using var scope = app.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync();
+}
+if (args.Contains("--migrate")) return;
+
+app.UseExceptionHandler(handler => handler.Run(async context =>
+{
+ context.Response.StatusCode = 503;
+ await context.Response.WriteAsJsonAsync(new { error = "The learning account service is temporarily unavailable. Your browser practice remains available." });
+}));
+app.Use(async (context, next) =>
+{
+ // A configured canonical origin is trusted, never arbitrary forwarded headers.
+ // OAuth cookies and redirect_uri therefore refer to the frontend BFF origin.
+ context.Request.Scheme = platform.PublicOrigin.Scheme;
+ context.Request.Host = new HostString(platform.PublicOrigin.Authority);
+ context.Response.Headers.CacheControl = "private, no-store";
+ context.Response.Headers.XContentTypeOptions = "nosniff";
+ await next();
+});
+app.UseAuthentication();
+app.UseAuthorization();
+app.UseAntiforgery();
+app.MapAccountEndpoints();
+app.MapProgressEndpoints();
+app.MapGet("/api/platform/health", () => Results.Json(new { status = "ok" }));
+app.Run();
+
+public partial class Program { }
diff --git a/backend/src/ProCoderJi.Api/Properties/launchSettings.json b/backend/src/ProCoderJi.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..fa1348f
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5100",
+ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }
+ }
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/appsettings.Development.json b/backend/src/ProCoderJi.Api/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/appsettings.json b/backend/src/ProCoderJi.Api/appsettings.json
new file mode 100644
index 0000000..127eb7e
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "PublicOrigin": "http://localhost:3000",
+ "Database": {
+ "Provider": "Sqlite",
+ "MigrateOnStartup": false
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/backend/src/ProCoderJi.Api/packages.lock.json b/backend/src/ProCoderJi.Api/packages.lock.json
new file mode 100644
index 0000000..06e0e18
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/packages.lock.json
@@ -0,0 +1,286 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.AspNetCore.Authentication.Google": {
+ "type": "Direct",
+ "requested": "[10.0.14, )",
+ "resolved": "10.0.14",
+ "contentHash": "3enUvuEqorstWs1o2wc2hd8VgiF+fK0vZtGXl3wjE760887BNXUTh9vl86cLY+qdDujCHHWxA773nkWW4n8FBA=="
+ },
+ "Microsoft.AspNetCore.DataProtection.EntityFrameworkCore": {
+ "type": "Direct",
+ "requested": "[10.0.14, )",
+ "resolved": "10.0.14",
+ "contentHash": "zXov4zfbIsBCAYniekMemgWSAMpfzQ1jhWGs4k2WIr0n1Ac4osMD1xbqf31CllPZ2z4dTsYzWRmGZO+czKGOxw==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.14"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Design": {
+ "type": "Direct",
+ "requested": "[10.0.14, )",
+ "resolved": "10.0.14",
+ "contentHash": "hRaYw8yzhpGq3Yz9apZrr/tp6FeBDUiAq4zJELRxQH1J1PfSBb2uGZoXlOL6EoSB3/M+OMpIjHDH0aOfgwyUUQ==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "18.0.2",
+ "Microsoft.CodeAnalysis.CSharp": "5.0.0",
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.14",
+ "Microsoft.Extensions.DependencyModel": "10.0.14",
+ "Mono.TextTemplating": "3.0.0",
+ "Newtonsoft.Json": "13.0.4"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Sqlite": {
+ "type": "Direct",
+ "requested": "[10.0.14, )",
+ "resolved": "10.0.14",
+ "contentHash": "lxrwnVwvlIfQD4IF4WUVupE6Qxfj3MfFftDVwo7tsGAWCKLL8Ttb8UjIt+gE4e9nRlH7EgdC6TrsArFGnNYREw==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.14",
+ "Microsoft.Extensions.DependencyModel": "10.0.14",
+ "SQLitePCLRaw.bundle_e_sqlite3": "2.1.13",
+ "SQLitePCLRaw.core": "2.1.13"
+ }
+ },
+ "Npgsql.EntityFrameworkCore.PostgreSQL": {
+ "type": "Direct",
+ "requested": "[10.0.3, )",
+ "resolved": "10.0.3",
+ "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
+ "Npgsql": "10.0.3"
+ }
+ },
+ "Humanizer.Core": {
+ "type": "Transitive",
+ "resolved": "2.14.1",
+ "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
+ },
+ "Microsoft.Build.Framework": {
+ "type": "Transitive",
+ "resolved": "18.0.2",
+ "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
+ },
+ "Microsoft.CodeAnalysis.Analyzers": {
+ "type": "Transitive",
+ "resolved": "3.11.0",
+ "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
+ },
+ "Microsoft.CodeAnalysis.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "17.11.31",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
+ "Newtonsoft.Json": "13.0.3",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.Data.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "1p9rO3Q2X/L/9YrYog3vaDzi8u+WZfJCOkDYoHqmofqYP0DLPLPyZcS2Rnh/79JXAodgbs1AgsaNwLpIvC3Xew==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.13"
+ }
+ },
+ "Microsoft.EntityFrameworkCore": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "vY3931PYPIDBbKovGHbACmpOWvURvKNyRXfCjMh5yLW+FH6vLWeqUu2xzSCRo4Oz4tgx9GYpd0JlV5LDyAoQdg==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "10.0.14",
+ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.14"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "/SaznoTTFfKzxYEbCPAwwKBR3XT92DZgH4jJ3/cqs2skP3o2D+TL6i9Aaj+8Azwtu6/sRd0ve4P2mN12nfVmCw=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "gaANlGNTxkgODX2eeVT8jjd3AmlFilQV4DFf8qoW30+wUCm2o4N7nEHtZvnHIwfhaJni2DqcjE47Au7tC0Q5jA=="
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "Ut0kgtfoKX9QElQVEXSEAgdQc79jISWStVWSOhfPDDHNSKMwFzSXG3gtdYFTAbsBoV+8NtXdVaj4xwdqeky8UA==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.14"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "08VGlMQ9WbK869as9Ho+VZQbM5Qu6zdorgeh6ShVwQgLYgfUBiaIm02T7spfW7Xc1MBHCvclwPgGWpvx5BYGXQ==",
+ "dependencies": {
+ "Microsoft.Data.Sqlite.Core": "10.0.14",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.14",
+ "Microsoft.Extensions.DependencyModel": "10.0.14",
+ "SQLitePCLRaw.core": "2.1.13"
+ }
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "N8OcqM0370wSXMJ3ZxO66rSElHrIgxvpfUn0vV7zRj7NvKo0nfwgEoTmC3Yz3860o60IaQ4KIPyeLIGX9EeNWA=="
+ },
+ "Microsoft.VisualStudio.SolutionPersistence": {
+ "type": "Transitive",
+ "resolved": "1.0.52",
+ "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
+ },
+ "Mono.TextTemplating": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.4",
+ "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
+ },
+ "Npgsql": {
+ "type": "Transitive",
+ "resolved": "10.0.3",
+ "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
+ },
+ "SQLitePCLRaw.bundle_e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.13",
+ "contentHash": "NlDixlC7uHmo+ojXRgkNvN0tQtVGpLDZ3x2OpgNj+G07VHh5cXSszFDrAiC4BpUiPwkcaeuQkMV9WQ123xVtmQ==",
+ "dependencies": {
+ "SQLitePCLRaw.lib.e_sqlite3": "2.1.13",
+ "SQLitePCLRaw.provider.e_sqlite3": "2.1.13"
+ }
+ },
+ "SQLitePCLRaw.core": {
+ "type": "Transitive",
+ "resolved": "2.1.13",
+ "contentHash": "2joofoinuHBFs0nMX3nKuXgxpXryRkCOdM2Ua6HBqQLLy6IW+5f28ZI+HAvMyF8vG7XfNz/lwfBfE6V4juru8Q=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.13",
+ "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A=="
+ },
+ "SQLitePCLRaw.provider.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.13",
+ "contentHash": "ZU60dSqoSNRe49KcQDUmOVgdRtjITOssVXFtiISs+zJXDI4D2Sndwpn2NOtzDQNPu0ZAvpoLPQ+GLKv6iEZn8Q==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.13"
+ }
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Composition": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Convention": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0",
+ "System.Composition.TypedParts": "9.0.0"
+ }
+ },
+ "System.Composition.AttributedModel": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
+ },
+ "System.Composition.Convention": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0"
+ }
+ },
+ "System.Composition.Hosting": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
+ "dependencies": {
+ "System.Composition.Runtime": "9.0.0"
+ }
+ },
+ "System.Composition.Runtime": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
+ },
+ "System.Composition.TypedParts": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs
new file mode 100644
index 0000000..bc29e22
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs
@@ -0,0 +1,95 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Npgsql;
+using ProCoderJi.Api.Data;
+
+namespace ProCoderJi.Api.Tests;
+
+public sealed class PlatformFactory(bool googleConfigured = false, string publicOrigin = "http://localhost:3000", bool testAuthentication = true) : WebApplicationFactory
+{
+ private readonly string sqlitePath = Path.Combine(Path.GetTempPath(), $"procoderji-{Guid.NewGuid():N}.db");
+ private readonly string? postgresConnection = Environment.GetEnvironmentVariable("PROCODERJI_TEST_POSTGRES");
+ private string? schema;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Testing");
+ var connection = $"Data Source={sqlitePath};Pooling=False";
+ var provider = "Sqlite";
+ if (!string.IsNullOrWhiteSpace(postgresConnection))
+ {
+ provider = "Postgres";
+ schema = "procoderji_test_" + Guid.NewGuid().ToString("N");
+ using var admin = new NpgsqlConnection(postgresConnection);
+ admin.Open();
+ using var command = new NpgsqlCommand($"CREATE SCHEMA \"{schema}\"", admin);
+ command.ExecuteNonQuery();
+ connection = new NpgsqlConnectionStringBuilder(postgresConnection) { SearchPath = schema }.ConnectionString;
+ }
+ builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["PublicOrigin"] = publicOrigin,
+ ["Database:Provider"] = provider,
+ ["ConnectionStrings:Platform"] = connection,
+ ["Authentication:Google:ClientId"] = googleConfigured ? "test-client.apps.googleusercontent.com" : "",
+ ["Authentication:Google:ClientSecret"] = googleConfigured ? "test-only-not-a-real-secret" : "",
+ }));
+ if (testAuthentication)
+ builder.ConfigureTestServices(services => services.AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = "Test";
+ options.DefaultChallengeScheme = "Test";
+ }).AddScheme("Test", _ => { }));
+ }
+
+ public HttpClient Browser(Guid? userId = null)
+ {
+ var client = CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri(publicOrigin), AllowAutoRedirect = false, HandleCookies = true });
+ if (userId is not null) client.DefaultRequestHeaders.Add("X-Test-User", userId.Value.ToString());
+ return client;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing) return;
+ var completedSchema = schema;
+ schema = null;
+ if (completedSchema is not null && postgresConnection is not null)
+ {
+ using var connection = new NpgsqlConnection(postgresConnection);
+ connection.Open();
+ using var command = new NpgsqlCommand($"DROP SCHEMA IF EXISTS \"{completedSchema}\" CASCADE", connection);
+ command.ExecuteNonQuery();
+ }
+ if (File.Exists(sqlitePath)) File.Delete(sqlitePath);
+ }
+}
+
+// This handler exists only in the test assembly and is registered by the factory.
+// The API has no test-header authentication path in any environment.
+internal sealed class TestAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IDbContextFactory factory)
+ : AuthenticationHandler(options, logger, encoder)
+{
+ protected override async Task HandleAuthenticateAsync()
+ {
+ if (!Guid.TryParse(Request.Headers["X-Test-User"], out var id)) return AuthenticateResult.NoResult();
+ await using var db = await factory.CreateDbContextAsync(Context.RequestAborted);
+ if (!await db.Users.AnyAsync(user => user.Id == id, Context.RequestAborted))
+ {
+ db.Users.Add(new LearnerUser { Id = id, GoogleSubject = "test-" + id, Email = $"{id:N}@example.test", Name = "Test student", CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
+ await db.SaveChangesAsync(Context.RequestAborted);
+ }
+ var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, id.ToString()), new Claim(ClaimTypes.Email, $"{id:N}@example.test"), new Claim(ClaimTypes.Name, "Test student")], Scheme.Name);
+ return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
+ }
+}
diff --git a/backend/tests/ProCoderJi.Api.Tests/PlatformIntegrationTests.cs b/backend/tests/ProCoderJi.Api.Tests/PlatformIntegrationTests.cs
new file mode 100644
index 0000000..9a5e1c3
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/PlatformIntegrationTests.cs
@@ -0,0 +1,137 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication.Google;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace ProCoderJi.Api.Tests;
+
+public sealed class PlatformIntegrationTests
+{
+ private static readonly Guid UserA = Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
+ private static readonly Guid UserB = Guid.Parse("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ [Fact]
+ public async Task Missing_google_configuration_preserves_guest_mode_and_does_not_trust_test_headers()
+ {
+ using var factory = new PlatformFactory(testAuthentication: false);
+ using var browser = factory.Browser(UserA);
+ var session = await Session(browser);
+ Assert.False(session.GetProperty("configured").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, session.GetProperty("user").ValueKind);
+ Assert.NotEmpty(session.GetProperty("csrfToken").GetString()!);
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, (await browser.GetAsync("/api/platform/auth/google?returnTo=/learn")).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized, (await browser.GetAsync("/api/platform/progress")).StatusCode);
+ }
+
+ [Theory]
+ [InlineData("http://localhost:3000")]
+ [InlineData("https://students.example.test")]
+ public async Task Google_challenge_uses_frontend_callback_and_rejects_external_return_targets(string origin)
+ {
+ using var factory = new PlatformFactory(googleConfigured: true, publicOrigin: origin);
+ using var browser = factory.Browser();
+ var response = await browser.GetAsync("/api/platform/auth/google?returnTo=" + Uri.EscapeDataString("//evil.example/test"));
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ var location = response.Headers.Location!;
+ Assert.Equal("accounts.google.com", location.Host);
+ var query = QueryHelpers.ParseQuery(location.Query);
+ Assert.Equal(origin + "/api/platform/signin-google", query["redirect_uri"].ToString());
+ var options = factory.Services.GetRequiredService>().Get("Google");
+ var properties = options.StateDataFormat.Unprotect(query["state"].ToString());
+ Assert.Equal(origin + "/learn", properties!.RedirectUri);
+ Assert.DoesNotContain(":0/", query["redirect_uri"].ToString());
+ }
+
+ [Fact]
+ public async Task Progress_is_isolated_and_the_same_guest_uuid_can_be_imported_by_two_accounts()
+ {
+ using var factory = new PlatformFactory();
+ using var first = factory.Browser(UserA);
+ using var second = factory.Browser(UserB);
+ var firstToken = (await Session(first)).GetProperty("csrfToken").GetString()!;
+ var secondToken = (await Session(second)).GetProperty("csrfToken").GetString()!;
+ var firstState = await Progress(first, UserA);
+ var secondState = await Progress(second, UserB);
+ var id = Guid.NewGuid();
+ Assert.Equal(HttpStatusCode.OK, (await Write(first, HttpMethod.Post, UserA, firstToken, new { attempts = new[] { Attempt(id) }, generation = firstState.GetProperty("generation").GetString() })).StatusCode);
+ Assert.Empty((await Progress(second, UserB)).GetProperty("attempts").EnumerateArray());
+ Assert.Equal(HttpStatusCode.OK, (await Write(second, HttpMethod.Post, UserB, secondToken, new { attempts = new[] { Attempt(id) }, generation = secondState.GetProperty("generation").GetString() })).StatusCode);
+ Assert.Single((await Progress(first, UserA)).GetProperty("attempts").EnumerateArray());
+ Assert.Single((await Progress(second, UserB)).GetProperty("attempts").EnumerateArray());
+ Assert.Equal(HttpStatusCode.Conflict, (await Write(first, HttpMethod.Post, UserB, firstToken, new { attempts = Array.Empty
-
4. If this changes
+
4. Retention and deletion
+
+ Guest history remains in your browser until you delete it, clear browser data, or older sessions fall
+ outside the 100-session limit. Cloud practice history keeps the most recent 100 sessions per account;
+ older sessions are removed as new sessions are synced. The Account & data page lets you export
+ the history currently visible in your workspace and delete practice progress. Cloud deletion resets
+ cached account history on other devices when they next sync. To request deletion of the sign-in
+ account or other data associated with your email, contact from that email address.
+
+
5. Future changes
If we add analytics, advertising (such as Google AdSense), embedded videos, a newsletter or anything
else that collects data or sets cookies, we will update this policy first, describe the third parties
involved, and ask for your consent where the law requires it.
-
5. Your choices and rights
+
6. Your choices and rights
Depending on where you live, you may have the right to ask what personal data we hold about you, to
have it corrected or deleted, and to object to its use. We handle personal data in line with
@@ -70,18 +93,18 @@ export default function PrivacyPage() {
force. To make a request, email . We will respond within a reasonable time.
-
6. Children
+
7. Children
The site is meant for students. We do not knowingly collect personal data from children through the
site. If you believe a child has sent us personal data, email us and we will delete it.
-
7. Changes to this policy
+
8. Changes to this policy
We may update this policy. The effective date at the top shows when the current version took effect.
-
8. Contact
+
9. Contact
Questions about this policy:
{LEGAL.postalAddress ? <> · {LEGAL.postalAddress}> : null}.
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index e777255..0a020a1 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -2,7 +2,7 @@ import { EmailLink, Fill, H2, LegalLayout, Operator, P, UL, legalMetadata } from
export const metadata = legalMetadata(
"Terms of Use",
- "The terms for using ProCoderJii Notes.",
+ "Review the terms for using ProCoderJii Notes, including acceptable use, ownership of study material, and service limitations.",
"/terms"
);
@@ -22,10 +22,11 @@ export default function TermsPage() {
2. Ownership of content
- Unless a page says otherwise, the notes and other original content on this site are the copyright of{" "}
- . All rights are reserved. You may read the content online and keep a
- copy for your own personal, non-commercial study. You may not copy, republish, sell, redistribute or
- use it to build another product or dataset without our prior written permission.
+ The public project repository is licensed under the MIT License, with separate notices for
+ third-party material. Reuse of source code and repository content follows that license and any
+ applicable file-specific notices. If a learning resource states a separate license, follow its
+ stated terms. The repository license is available at{" "}
+ GitHub.
Names, logos and materials belonging to universities, exam bodies, YouTube, Google and other third
diff --git a/src/components/landing/hero.tsx b/src/components/landing/hero.tsx
index cdaf6bb..58574f6 100644
--- a/src/components/landing/hero.tsx
+++ b/src/components/landing/hero.tsx
@@ -151,20 +151,24 @@ export function Hero() {
>
- Study Material for
+ Build skills for
- ProCoderJii Community
+ your next opportunity
- Free notes and unit-wise guides to go with the ProCoderJii YouTube videos. Pick your branch and
- semester to see what is available - more material is added as it is reviewed.
+ Practise aptitude, reasoning, verbal ability, and programming with clear explanations.
+ Save your progress, find your next topic, and build a study routine at your own pace.
+
+ Start placement practice
+ My learning workspace
+