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) -[![CI](https://github.com/Coding-Professional/procoderji/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Coding-Professional/procoderji/actions/workflows/ci.yml) +[![CI](https://github.com/procoderji-com/procoderji/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/procoderji-com/procoderji/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=next.js)](https://nextjs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript)](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. + +![Guest learning workspace before the first session](docs/images/learning-workspace.png) + +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(), generation = firstState.GetProperty("generation").GetString() })).StatusCode); + } + + [Fact] + public async Task Cookie_mutations_require_same_origin_and_valid_antiforgery_tokens() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = (await Session(browser)).GetProperty("csrfToken").GetString()!; + var generation = (await Progress(browser, UserA)).GetProperty("generation").GetString(); + var body = new { attempts = new[] { Attempt(Guid.NewGuid()) }, generation }; + Assert.Equal(HttpStatusCode.Forbidden, (await Write(browser, HttpMethod.Post, UserA, null, body)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await Write(browser, HttpMethod.Post, UserA, token, body, "https://evil.example")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Write(browser, HttpMethod.Post, UserA, token, body)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await Write(browser, HttpMethod.Post, UserA, null, null, path: "/api/platform/auth/logout")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await Write(browser, HttpMethod.Post, UserA, token, null, path: "/api/platform/auth/logout")).StatusCode); + } + + [Fact] + public async Task Reset_rotates_generation_and_rejects_old_uploads_and_retries_are_immutable() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = (await Session(browser)).GetProperty("csrfToken").GetString()!; + var generation = (await Progress(browser, UserA)).GetProperty("generation").GetString(); + var id = Guid.NewGuid(); + Assert.Equal(HttpStatusCode.OK, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = new[] { Attempt(id, "a") }, generation })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = new[] { Attempt(id, "b") }, generation })).StatusCode); + var saved = (await Progress(browser, UserA)).GetProperty("attempts").EnumerateArray().Single(); + Assert.Equal("a", saved.GetProperty("responses")[0].GetProperty("optionId").GetString()); + var reset = await Write(browser, HttpMethod.Delete, UserA, token, null); + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + var changed = (await reset.Content.ReadFromJsonAsync()).GetProperty("generation").GetString(); + Assert.NotEqual(generation, changed); + Assert.Empty((await Progress(browser, UserA)).GetProperty("attempts").EnumerateArray()); + Assert.Equal(HttpStatusCode.Conflict, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = new[] { Attempt(id) }, generation })).StatusCode); + } + + [Fact] + public async Task Cloud_history_retains_newest_100_and_invalid_or_claimed_score_payloads_are_rejected() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = (await Session(browser)).GetProperty("csrfToken").GetString()!; + var generation = (await Progress(browser, UserA)).GetProperty("generation").GetString(); + Assert.Equal(HttpStatusCode.OK, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = Enumerable.Range(0, 100).Select(_ => Attempt(Guid.NewGuid())).ToArray(), generation })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = Enumerable.Range(0, 10).Select(_ => Attempt(Guid.NewGuid())).ToArray(), generation })).StatusCode); + Assert.Equal(100, (await Progress(browser, UserA)).GetProperty("attempts").GetArrayLength()); + Assert.Equal(HttpStatusCode.BadRequest, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = Enumerable.Range(0, 101).Select(_ => Attempt(Guid.NewGuid())).ToArray(), generation })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = new[] { new { id = Guid.NewGuid(), completedAt = "2026-11-01T10:00:00.000Z", topicId = "quantitative", questionIds = new[] { "unknown-question" }, responses = Array.Empty(), durationSeconds = 1 } }, generation })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await Write(browser, HttpMethod.Post, UserA, token, new { attempts = new[] { new { id = Guid.NewGuid(), scorePercent = 100 } }, generation })).StatusCode); + } + + private static object Attempt(Guid id, string option = "a") => new { id, completedAt = "2026-11-01T10:00:00.000Z", topicId = "quantitative", questionIds = new[] { "quant-discount-price" }, responses = new[] { new { questionId = "quant-discount-price", optionId = option } }, durationSeconds = 10 }; + private static async Task Session(HttpClient browser) => await browser.GetFromJsonAsync("/api/platform/auth/session", Json); + private static async Task Progress(HttpClient browser, Guid user) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/platform/progress"); + request.Headers.Add("x-account-id", user.ToString()); + using var response = await browser.SendAsync(request); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(Json); + } + private static Task Write(HttpClient browser, HttpMethod method, Guid user, string? token, object? body, string origin = "http://localhost:3000", string path = "/api/platform/progress") + { + var request = new HttpRequestMessage(method, path); + request.Headers.Add("x-account-id", user.ToString()); + request.Headers.Add("Origin", origin); + if (token is not null) request.Headers.Add("X-CSRF-Token", token); + if (body is not null) request.Content = JsonContent.Create(body, options: Json); + return browser.SendAsync(request); + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/PracticeQuestionBankTests.cs b/backend/tests/ProCoderJi.Api.Tests/PracticeQuestionBankTests.cs new file mode 100644 index 0000000..6ed5cc3 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/PracticeQuestionBankTests.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ProCoderJi.Api.Features.Practice; +using Xunit; + +namespace ProCoderJi.Api.Tests; + +public sealed class PracticeQuestionBankTests +{ + private const string QuestionId = "quant-discount-price"; + private static readonly DateTimeOffset Now = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero); + private static string BankJson => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "questions.json")); + private static PracticeQuestionBank Bank() => PracticeQuestionBank.FromJson(BankJson); + private static ProgressAttemptDto Attempt() => new( + Guid.Parse("c359a679-f4be-4b0d-8c2b-ff0314e37228"), + "2026-11-01T10:00:00.000Z", "quantitative", [QuestionId], + [new PracticeResponseDto(QuestionId, "b")], 30); + + [Fact] + public void SharedBankLoadsAllTwentyFourCanonicalQuestions() + { + Assert.True(Bank().QuestionCount >= 24); + } + + [Fact] + public void ValidatedAttemptsKeepRawResponsesAndRoundTripCanonicalJavascriptDates() + { + var bank = Bank(); + Assert.True(bank.TryValidate(Attempt(), Now, out var valid)); + Assert.NotNull(valid); + Assert.Equal("2026-11-01T10:00:00.000Z", valid.ToDto().CompletedAt); + Assert.Equal(Attempt().Id, valid.Id); + Assert.Single(valid.Responses); + Assert.True(bank.TryValidate(Attempt() with { Responses = [] }, Now, out var unanswered)); + Assert.Empty(unanswered!.Responses); + } + + [Fact] + public void CanonicalQuestionsTopicsAndOptionsAreRequired() + { + var bank = Bank(); + Assert.False(bank.TryValidate(null, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { Id = Guid.Empty }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { TopicId = "unknown" }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { TopicId = "reasoning" }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = ["invented-question"] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { Responses = [new(QuestionId, "z")] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { Responses = [new("quant-ratio-share", "a")] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = null }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = [null!] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { Responses = null }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { Responses = [null!] }, Now, out _)); + } + + [Fact] + public void QuestionsAndResponsesCannotBeCountedTwice() + { + var bank = Bank(); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = [QuestionId, QuestionId] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = [QuestionId, "quant-ratio-share"], Responses = [new(QuestionId, "a"), new(QuestionId, "b")] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = [] }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { QuestionIds = Enumerable.Repeat(QuestionId, 31).ToList() }, Now, out _)); + } + + [Theory] + [InlineData("2026-02-31T10:00:00.000Z")] + [InlineData("1970-01-01T00:00:00.000Z")] + [InlineData("2026-11-10T12:05:00.001Z")] + [InlineData("2026-11-01T10:00:00.0000000Z")] + [InlineData("2026-11-01T10:00:00.000+00:00")] + [InlineData("")] + public void NonCanonicalImpossibleOrFutureTimestampsAreRejected(string completedAt) + { + Assert.False(Bank().TryValidate(Attempt() with { CompletedAt = completedAt }, Now, out _)); + } + + [Fact] + public void DurationAndClockSkewLimitsMatchTheFrontend() + { + var bank = Bank(); + Assert.False(bank.TryValidate(Attempt() with { DurationSeconds = -1 }, Now, out _)); + Assert.False(bank.TryValidate(Attempt() with { DurationSeconds = 7_201 }, Now, out _)); + Assert.True(bank.TryValidate(Attempt() with { DurationSeconds = 7_200, CompletedAt = "2026-11-10T12:05:00.000Z" }, Now, out _)); + } + + [Fact] + public void ValidatedCollectionsAreCopiedFromMutableRequestLists() + { + var input = Attempt(); + Assert.True(Bank().TryValidate(input, Now, out var valid)); + input.QuestionIds!.Clear(); + input.Responses!.Clear(); + Assert.Single(valid!.QuestionIds); + Assert.Single(valid.Responses); + var output = valid.ToDto(); + output.QuestionIds!.Clear(); + output.Responses!.Clear(); + Assert.Single(valid.QuestionIds); + Assert.Single(valid.Responses); + } + + [Fact] + public void JsonContractsRejectMissingFieldsAndClientProvidedScores() + { + var payload = JsonNode.Parse(JsonSerializer.Serialize(Attempt()))!.AsObject(); + payload["scorePercent"] = 100; + Assert.Throws(() => JsonSerializer.Deserialize(payload.ToJsonString())); + payload.Remove("scorePercent"); + payload.Remove("durationSeconds"); + Assert.Throws(() => JsonSerializer.Deserialize(payload.ToJsonString())); + var response = "{\"questionId\":\"quant-discount-price\",\"optionId\":\"b\",\"correct\":true}"; + Assert.Throws(() => JsonSerializer.Deserialize(response)); + } + + [Fact] + public void BankStartupRejectsAmbiguousQuestionsAndUnknownFields() + { + var duplicate = JsonNode.Parse(BankJson)!.AsArray(); + duplicate.Add(duplicate[0]!.DeepClone()); + Assert.Throws(() => PracticeQuestionBank.FromJson(duplicate.ToJsonString())); + var invalidAnswer = JsonNode.Parse(BankJson)!.AsArray(); + invalidAnswer[0]!["answerId"] = "z"; + Assert.Throws(() => PracticeQuestionBank.FromJson(invalidAnswer.ToJsonString())); + var unknown = JsonNode.Parse(BankJson)!.AsArray(); + unknown[0]!["officialCompanyQuestion"] = true; + Assert.Throws(() => PracticeQuestionBank.FromJson(unknown.ToJsonString())); + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj new file mode 100644 index 0000000..c3ef368 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + diff --git a/backend/tests/ProCoderJi.Api.Tests/packages.lock.json b/backend/tests/ProCoderJi.Api.Tests/packages.lock.json new file mode 100644 index 0000000..00f2e86 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/packages.lock.json @@ -0,0 +1,602 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.14, )", + "resolved": "10.0.14", + "contentHash": "l3eFye4Bv4oGT1Ag8pFtZ6WM0LhR9PkNiDH1AYitZW2LAaFWl1FgF4Dj7wY4Fp/BqCZ4LLkBGLw+NE/CA5k6mQ==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.14", + "Microsoft.Extensions.DependencyModel": "10.0.14", + "Microsoft.Extensions.Hosting": "10.0.14" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "Microsoft.AspNetCore.Authentication.Google": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "3enUvuEqorstWs1o2wc2hd8VgiF+fK0vZtGXl3wjE760887BNXUTh9vl86cLY+qdDujCHHWxA773nkWW4n8FBA==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "hwsfpgYV5uYh/JLzjS9qdNBWwjL6dRbs4naGku9fJKLwrIJFTtRSincH2P060ZQUKdFFRzG+uT4hinr85sZtlQ==" + }, + "Microsoft.AspNetCore.DataProtection": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "gDUH0z2bK0lPaHGy2/pIQkEtbhG3dFGSIbioy+Qu76Y+HiB0xg2TFqlSLc+DjQ2Jb/3GODGMtTh4JuufVWG7Dw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.14", + "Microsoft.AspNetCore.DataProtection.Abstractions": "10.0.14", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14", + "System.Security.Cryptography.Xml": "10.0.14" + } + }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "/4+1KX8VIKCCHZ8p/1dZ2WGxvIijm5lXmgHecxEwGnBYXiSjAqfF/Q7p9m6eojUDlpKMHkt1HHXeNdL9WH3YHw==" + }, + "Microsoft.AspNetCore.DataProtection.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "zXov4zfbIsBCAYniekMemgWSAMpfzQ1jhWGs4k2WIr0n1Ac4osMD1xbqf31CllPZ2z4dTsYzWRmGZO+czKGOxw==", + "dependencies": { + "Microsoft.AspNetCore.DataProtection": "10.0.14", + "Microsoft.EntityFrameworkCore": "10.0.14" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "u4AX1M5Uzmw7V3NCdJJO4ITwH0DDDufyzmVlksE4QguVd23Co8adaREMp2VahlRB5f/rmtaFYVJIA1iIFnchmQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "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.Extensions.Caching.Memory": "10.0.14", + "Microsoft.Extensions.Logging": "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.Extensions.Caching.Memory": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "lxrwnVwvlIfQD4IF4WUVupE6Qxfj3MfFftDVwo7tsGAWCKLL8Ttb8UjIt+gE4e9nRlH7EgdC6TrsArFGnNYREw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.14", + "Microsoft.Extensions.Caching.Memory": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.DependencyModel": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.13", + "SQLitePCLRaw.core": "2.1.13" + } + }, + "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.Caching.Memory": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.DependencyModel": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "SQLitePCLRaw.core": "2.1.13" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "enSfqeENnSjGH3YAYKsCtsa5ob0HNiwF2nwJiC2yhilNXTlD/0prxrgPkWjoAp9svJcu04AiEIKtdh/A54P2fA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "AxK3kyD6sWNpYKf+66huE7wHHjgKHONlD1dUaD/CxjZVBLEpH5N4jaqgszTcZvaM9/XVeXF28K3E0wsTyTA4Xg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.14", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "UcnvaEaMvRckDDhyqY1OZkFwBqpZArkHFKB6dWaQWT9XTuNttNNX8Cqsb/Sic2LfJxsMLJUXnYrxY3iX5LN9pQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "4YdfvaoWn3tB1xQQHGrhRTFWJ8fGOqxThxduynSZy9ZFTiSaUi3vX4EK/kj8h2HQMwTlks9MWV1fv2WRBv5/1w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "brDb5kgle2iY2lWHgeV/KOqnBEpVnlMFigGJ/WW7KP9Nj6C/t53f/h5boYyVXB0bEhgpL+ctbewDJMI+zAMj1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "/9IqkBeB7mE1enbN4Cfeu4HFPU9uR1JSJh2qwXfwr7SYBxETkTKRht/LoPfajmmlh+BJlH3jm79CLhQPFb9ovw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "jB0UiYjpnneoBH1ZeXPlHYt4zUme6SZlnstL6gsH/0O/d5rOuN5lSIlOIfxXzapl8tAi7QmYOEogP09g/wcHzA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "NBIfm8vWNi3UJ9OI9thvREJeV6KzEdPjQ4ONdH+yPWutGBpVnwLbXiEqdEFaGAFYpAluGCfyCwKFgH9pd2scfw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.14", + "Microsoft.Extensions.FileProviders.Physical": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "4Tn8eNg85x+ICDN4OLC7Dl/xvZNREZIf7VSOAdT4Q4KYf5Ip1zAk+0BAx8BsRTomgEpCYV4kKsNNnP/ZXUbQkw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.14", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "z+dvl9uQnNzvq6Xco+UQyeujpP/BHs+ahHLPCBMxR5GW6M4JcsN1tupEgn5uWgR1fGCfmxS4QJEMt6eyizXGUA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Configuration.Json": "10.0.14", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.14", + "Microsoft.Extensions.FileProviders.Physical": "10.0.14" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "2LzcuVjemeXR+WdY9wGIr8iGb7fYsDAit+IScxXX2XJnX0b++mWcE/WO6fEGIqhdMfSQM2ZS5JGMHYZix7YXZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "QtpQzuSZVyTPCuHW058yCs9Nj0394uUkNyFHS813nOmK9UgUdBdaprJ5PPsBgsQIpxADTsTkbaMMonykem6qdw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "N8OcqM0370wSXMJ3ZxO66rSElHrIgxvpfUn0vV7zRj7NvKo0nfwgEoTmC3Yz3860o60IaQ4KIPyeLIGX9EeNWA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "80VqW8eKxQaxBOLDNpLUdxOlxzWn/jqBSxNBCfD8GCjcshNDpFvUgKh8uvvZwvkfBbwmNPWPWHyy9F+RLd1dlQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.14", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.14" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "vm+PPso6vOwGeX52MdOPh6z/R6KHgxQUwqq7k7Jc9jhy2JOxO2KB4xfoJQBBp2Ni8M4zKgJ4r2a1dgrYG7+MKw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "rAqf2Em2ZjLqNbcLJlboQnXNyJP604F960TsCKNiP00Ur6ExIc1mcwmNb8L2eTRT8RC8UKx/GjecoWcW2Sj7bw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "dcp6eCIk6ZpE3CTNmIna0ifgxsPel/MtOc23/Y/AJlD9SZZr7bhlKK2DAXYlrz81yMkMi6el/LHHUFKCZ3ufuQ==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.14", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "xt6c/PWLL6+CiBiZA3PWRSfkwcPh+HCwiOLrgSUkhzBT7lrT/+QpIKPeuCQyDPspwOuG1q0cbJhuiGpRjr4UNQ==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "q3f2GLZebtxWG/pDtckPrgIWV22pVmSLSLEkncdmbUz7Z6atwF2wn9ag5cZCc/ndcGe7ffHJom98scauFG+oNQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Configuration.Binder": "10.0.14", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.14", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.14", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.14", + "Microsoft.Extensions.Configuration.Json": "10.0.14", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.14", + "Microsoft.Extensions.DependencyInjection": "10.0.14", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Diagnostics": "10.0.14", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.14", + "Microsoft.Extensions.FileProviders.Physical": "10.0.14", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging.Configuration": "10.0.14", + "Microsoft.Extensions.Logging.Console": "10.0.14", + "Microsoft.Extensions.Logging.Debug": "10.0.14", + "Microsoft.Extensions.Logging.EventLog": "10.0.14", + "Microsoft.Extensions.Logging.EventSource": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "p6NUEkmlaB+FujQZTtyNBKFULQO+Fj8SKuYdYHASQso+RkeYdFDPNsMM4mtOHqx1GZNQnZm/inmsqGkja/Dw2w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.14", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "O2fXL6JmtkFwkigxS8fPTQpLXxGDjgZco5D+h88s++Cl0bDCs023PVkpNoifciJ+f30I82jnp8tpwF0wCLdFtA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "SwcbEBtkECq3D2oywvHkm46fyrX9Dd2JPWphDnHUc2+cUWR1Mdo41atAjh/9iZjgQxuvbKfZKXqostT/LZfefA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "CfFSNbVSXSl8UTH453c0HoY5owcpaY1PMh1xsB+3NWcEq6WUSPHRotydbnMJtmw7Kybp6cTba8e+/uaJI52ANw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.14", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Configuration.Binder": "10.0.14", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.14" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "8Qx57p3sUAajSZHLQf8eihZqMtSIeI4fZ1JVpL4fvg4QqSbbgJkN0X0owX1cVJJRW2Xw4uqtVv9F7LgdXOBQHQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging.Configuration": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "8e79uonXzgIsZbdgc5pa2Ybk5W+vUbd+gfGS50E+BW9ApRv9s0qL1DTyqLv7VQEPxTHS/l8Y0AtS7K8do+TczQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "4HbWayQiJdw0lYthC45rElah2mJJeSlHDu3rcIrinGe95JdOt6I/vWQTNUFXiujIsHv3Z8MyA79ZC5P1Ww+jNg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14", + "System.Diagnostics.EventLog": "10.0.14" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "Q06Q90D3qnrsKtf1QlaMELCED3ZdMV5SGIRIERUQTTSCoqTNKSitx3KMRgLdxBeqI5ZcbOu5InOu6xQ6o6zmUg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Logging": "10.0.14", + "Microsoft.Extensions.Logging.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "EeN/33S418m9oWEaZY4N5T+BNxtOqNTIFoFbIgBKwylh4OJPKIWFAzbIhpTdNbwUs26MhkmbLCAbdY+YCJ1dIQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "4wcIU0rE04ejEfCwHTftu9O8fgH6CRYCe+tseRapikUBn1Kic3QdO7ku22SwFyLrzdWGYPUZMEgv57ZYuuc5gA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.14", + "Microsoft.Extensions.Configuration.Binder": "10.0.14", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.14", + "Microsoft.Extensions.Options": "10.0.14", + "Microsoft.Extensions.Primitives": "10.0.14" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "1bbZqqCJTPba3inTADExbp/9PsGVyvGOX3zr6tFr7ySCgVkX9Lpw7BV0TizktNItd3SsbUu1sQfSAYm1GZMoNw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "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" + } + }, + "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.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "O/RzNwWoDzehkLlAG9hR8xooY5L41U7VUaYlcKvE3+FhHohBJqF10oLUzuv0v59VeJ62hv3uw9VoeX12zej7Sg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "Wz+IgL2xpg8KJ4i2VWj6uz1PmZNu4Q0Ljlwd73P9Vze5NcOBtctLii3itV0PB2MpOqwqDEGfB6RCdv94A4E5yQ==" + }, + "System.Security.Cryptography.Xml": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "qpDILpfJvj65+Pvpf5drWq2+mRNSrPffJsRYVQjBmEky+V34W+w2LXDZShq0miitf5qAPKuMAQ3CUaifaS2rrw==", + "dependencies": { + "System.Security.Cryptography.Pkcs": "10.0.14" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "procoderji.api": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Authentication.Google": "[10.0.14, )", + "Microsoft.AspNetCore.DataProtection.EntityFrameworkCore": "[10.0.14, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.14, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" + } + } + } + } +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6947689 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,42 @@ +# Documentation + +ProCoderJii is building a student learning and placement-practice product. Free practice and +browser-saved progress work without a production service account. Optional account +synchronization is a separate capability; paid billing is future work. + +## Start here + +| You want to... | Read | +| --- | --- | +| Understand the product and current content | [Project README](../README.md) | +| Review student value, scaling, and future monetization | [Product roadmap](product-roadmap.md) | +| Make your first contribution | [Contributing](../CONTRIBUTING.md) | +| Run the app and choose checks | [Development](development.md) | +| Understand routes, content, and feature boundaries | [Architecture](architecture.md) | +| Add original notes, guides, or career listings | [Content guide](content-guide.md) | +| Run the .NET API, Google sign-in, and account persistence | [Backend setup](../backend/README.md) | +| Configure production hosting and the site domain | [Deployment](deployment.md) | +| Report a vulnerability privately | [Security policy](../SECURITY.md) | +| Review implementation security notes | [Security notes](security.md) | +| Understand optional coding-agent tools | [Agent tools](agent-tools.md) | + +## Product decisions + +Architecture decisions describe a problem, the chosen boundary, and its consequences. Start with +[ADR 0001: student-product boundaries](decisions/0001-student-product-boundaries.md). Add a new +decision record when a change affects persistence, account requirements, payment handling, or +other feature boundaries; update the architecture guide when the implementation changes. + +The [project roadmap](https://github.com/procoderji-com/procoderji/issues/22) records product +direction. Check the linked issues and pull requests for current implementation status. A +roadmap item is a proposal until its behavior is implemented and documented. + +## Contribute documentation + +Keep setup instructions reproducible and distinguish available behavior from planned features. +Link to the canonical guide rather than copying setup steps into several files. When you change +a route, environment variable, data model, or command, update its guide in the same pull request. + +Deployment instructions may include dated observations and owner-specific operations. Recheck +those observations before acting on them; local development does not require DNS or production +hosting changes. diff --git a/docs/architecture.md b/docs/architecture.md index 5528e7f..5da38cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,66 +1,144 @@ # Architecture -This project is a content-driven Next.js App Router site. Most pages are generated from files in -the repository at build time. There is no database or account system. +ProCoderJii keeps its student interface in a Next.js App Router frontend and its optional +account service in ASP.NET Core 10. Guest practice works without the backend. Both runtimes +consume one canonical question bank at `shared/practice/questions.json`. -## Request and content flow +## Runtime boundaries ```mermaid flowchart TD - Browser --> App[Next.js routes in src/app] - App --> Catalog[src/data catalogues] - Catalog --> Notes[src/content Markdown and MDX] - App --> UI[src/components] - App --> Lib[src/lib validation and rendering] - Lib --> Safe[Rendered page content] - Notes --> Lib + Browser --> Frontend[Next.js student views] + Frontend --> Practice[Pure TypeScript practice rules] + Frontend --> Local[Validated browser progress] + Frontend --> BFF[Same-origin platform proxy] + BFF --> API[ASP.NET Core API] + API --> EF[EF Core account persistence] + EF --> Database[SQLite development or PostgreSQL] + API --> Google[Google sign-in] + Questions[Shared question JSON] --> Practice + Questions --> API + Frontend --> Content[Reviewed content catalogues] ``` -Course notes, guides and jobs use JSON catalogues that point to content files. Only entries in a -catalogue are published. Validation checks slugs, dates and file paths before content is used. -The note renderer accepts a safe Markdown subset; it rejects raw HTML, JSX, expressions and -imports. See [the content guide](content-guide.md) for authoring details. +The frontend owns question selection, learning feedback, and browser interaction. The backend +owns trusted account identity, persistence, antiforgery validation, and future server-owned +capabilities. The Next.js backend-for-frontend proxy keeps browser requests under the public +site origin. See [ADR 0001](decisions/0001-student-product-boundaries.md) for the decision. + +| Route | Responsibility | +| --- | --- | +| `/learn` | Learner workspace, topic progress, recent sessions, and next-practice guidance. | +| `/practice` | Discover the four practice topics. | +| `/practice/[topic]` | Complete questions and review answers and explanations. | +| `/account` | Optional Google sign-in and progress/data controls. | +| `/api/platform/*` | Next.js forwarding layer for the configured backend. | +| `/api/platform/auth/session` | Current account, account configuration status, and antiforgery token. | +| `/api/platform/signin-google` | Public Google callback forwarded to ASP.NET Core. | +| `/dashboard` | Course catalogue and reviewed note reader. | +| `/games` | Existing short reasoning and memory exercises. | + +## Shared questions and learning rules + +The first bank contains 24 original multiple-choice questions across quantitative aptitude, +logical reasoning, verbal ability, and programming foundations. Questions record topic, +difficulty, options, correct answer, and explanation in `shared/practice/questions.json`. +TypeScript and .NET adapters consume this file rather than maintaining independent copies. + +Practice-domain code under `src/features/practice/domain/` remains independent of React, +browser storage, authentication, and payment SDKs. Session summaries are derived from selected +answers. A submitted score is never treated as trusted account data; the backend validates +question/option IDs against the shared bank. + +Question IDs are part of the persistence contract. Wording corrections can retain an ID; a +semantic change needs a new ID so historical responses are not reinterpreted as a different +question. Shared-bank changes should pass both TypeScript and .NET validation tests. + +## Guest and account progress + +`src/features/progress/` validates untrusted attempts and maintains the latest 100 completed +sessions in browser state. Each browser store is scoped to `guest` or an authenticated account +ID. Signing in does not silently move guest history into an account; import is an explicit +student action. Browser storage failures leave practice usable with an appropriate message. + +When the backend is configured, account progress can synchronize across devices. Backend +queries and mutations derive the student from the authenticated cookie and authorize access to +that account's records. Progress resets advance the account's generation; stale uploads cannot +silently restore deleted history. A stale local account snapshot is replaced on sync. This can +discard offline attempts in that snapshot, so visible history can be exported before a reset or +reconnection. + +The existing `/games` exercises keep temporary round state and do not add records to the +placement-practice history. + +## Optional ASP.NET Core accounts + +The frontend reads `DOTNET_API_URL` only on the server. With no configured backend, free guest +practice and local progress remain available. Google client credentials are backend secrets, +configured through user-secrets for development or environment/secret management in hosting. + +Google signs the student into a backend cookie session. The Google OAuth callback is the public +frontend URL `/api/platform/signin-google`, which the proxy forwards. `PublicOrigin` tells the +backend the canonical frontend origin for those redirects. The private API origin is not a +Google callback URL. + +The session response supplies a CSRF token, and mutating requests send `X-CSRF-Token`. The +backend validates authentication, antiforgery state, attempt structure, and question references. +Private account responses must not be cached as public content. No privileged account credential +belongs in a browser variable. + +EF Core uses SQLite for local development and supports PostgreSQL through explicit operator +configuration. Data Protection keys are persisted through EF in the same database; deployments +with replicas must share the configured persistence and protect its access. Database schema +provisioning, Google setup, and production checks are documented in +[Backend setup](../backend/README.md). + +## Reviewed content + +Course notes, guides, and jobs use JSON catalogues pointing to repository content files. Only +registered entries are published. Validation checks slugs, dates, and safe file paths. The +renderer accepts a safe Markdown subset and rejects raw HTML, JSX, expressions, and imports; +read the [content guide](content-guide.md). + +The course catalogue currently has no published subjects or notes; guides and jobs are empty. +Sample notes are intentionally unregistered. Empty pages stay out of the index and sitemap. Old +`/courses/*` paths redirect to `/dashboard/*`. ## Main areas | Path | Responsibility | | --- | --- | -| `src/app/` | Routes, metadata, page layouts and sitemap/robots endpoints | -| `src/components/` | Shared site shell, landing-page sections, cards and reader UI | -| `src/content/` | Note and guide files | -| `src/data/courses.json` | Course, branch, semester, subject and unit catalogue | -| `src/data/guides.json` | Guide catalogue | -| `src/data/jobs.json` | Hand-curated job and internship catalogue | -| `src/lib/` | Validation, safe paths, Markdown parsing, dates, metadata and game logic | -| `e2e/` | Browser interactions, temporary fixtures and production crawl checks | -| `docs/` | Contributor, content, security and deployment documentation | -| `.agents/` | Optional coding-agent skills; see [agent tools](agent-tools.md) | -| `.claude/` | Pointers to the canonical copies under `.agents/skills/` | - -The old `/courses/*` routes are retained as redirects to `/dashboard/*`; new course work should -use the dashboard routes. - -## Games - -The games hub and individual games live under `src/app/games/`. Game components own their -temporary round state in the browser. Shared question data and digit generation live in -`src/lib/games.ts`; unit tests cover the generated content. Scores are not sent to a server or -saved between visits. - -## Rendering and search indexing - -- Content pages are included in the sitemap only when their referenced file can be read. -- Empty course sections are marked `noindex` and are omitted from the sitemap. -- Site-wide metadata and canonical URLs are built from `NEXT_PUBLIC_SITE_URL`, which defaults to - `https://procoderji.com`. -- Latest videos are fetched from YouTube's public RSS feed only when `YOUTUBE_CHANNEL_ID` is set. - -## Tests - -- `npm test` checks pure helpers, catalogues, file boundaries and content rules. -- `npm run launch-check` builds the real content and crawls routes, links, metadata and sitemap. -- `npm run e2e` installs synthetic content, checks desktop/mobile pages and interactions, then - restores the original files. It needs Chrome or Edge installed. - -See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup and the recommended checks. -For optional agent instructions, see [the agent tools guide](agent-tools.md). +| `src/app/` | Frontend routes, metadata, layouts, and same-origin backend proxy. | +| `src/features/practice/` | TypeScript practice rules, shared-bank adapter, and student views. | +| `src/features/progress/` | Attempt model, browser storage, and sync coordination. | +| `src/features/account/` | Frontend account API client and controls. | +| `src/components/` | Shared shell, landing sections, and reusable UI. | +| `shared/practice/questions.json` | Canonical original practice bank used by both runtimes. | +| `backend/src/ProCoderJi.Api/` | ASP.NET Core account API, Google auth, and EF persistence. | +| `backend/tests/` | Backend integration and validation tests. | +| `src/content/` and `src/data/` | Reviewed notes, guides, listings, and their catalogues. | +| `src/lib/` | Existing content rendering/validation, SEO, and game helpers. | +| `e2e/` | Browser interactions, synthetic fixtures, and production crawl. | +| `docs/` | Development, content, architecture, and decision guides. | + +## Rendering and checks + +Public practice discovery is independent of account setup. Course content enters the sitemap +only when its referenced file can be read; empty sections are `noindex`. Canonical metadata uses +`NEXT_PUBLIC_SITE_URL`, defaulting to `https://procoderji.com`. Latest videos use the public +YouTube feed only when `YOUTUBE_CHANNEL_ID` is configured. + +Frontend CI checks types, domain/store behavior, content rules, production builds, desktop/mobile +flows, and real-site links/metadata. Browser CI also starts a real .NET process with isolated +SQLite persistence and exercises the Next.js proxy and Google sign-in challenge/public callback +URL. It uses test client configuration and does not complete Google consent or token exchange. +Default local browser checks stay frontend-only unless `E2E_DOTNET=1` is set. + +CI retains failure screenshots for seven days. Backend CI restores, builds, and tests +`backend/ProCoderJi.slnx` with .NET 10 and checks PostgreSQL migrations. Controlled test +authentication requires no Google credentials; live OAuth and production deployment checks +remain operator tasks. See [Development](development.md#checks). + +Billing, checkout, and paid entitlements are not implemented. Razorpay or Paytm is a future +provider decision that also needs server-verified payment state, cancellation/support behavior, +and operational readiness. Keep payment decisions outside educational scoring rules. diff --git a/docs/content-guide.md b/docs/content-guide.md index 493f6a5..b5c001d 100644 --- a/docs/content-guide.md +++ b/docs/content-guide.md @@ -1,6 +1,7 @@ # Content guide: adding notes -Notes are plain files in this repository; there is no admin panel or database. +Learning notes are plain files in this repository; there is no content admin panel. The optional +account database stores practice progress, not published notes. Only publish **reviewed, original** material (or link to the official source). Do not paste other people's notes, papers or PDFs without written permission. diff --git a/docs/decisions/0001-student-product-boundaries.md b/docs/decisions/0001-student-product-boundaries.md new file mode 100644 index 0000000..825346c --- /dev/null +++ b/docs/decisions/0001-student-product-boundaries.md @@ -0,0 +1,70 @@ +# ADR 0001: student-product boundaries + +- **Status:** Accepted for the current implementation +- **Date:** 2026-11-10 + +## Context + +The next useful student workflow is a placement-practice loop: choose a topic, answer questions, +understand explanations, and see progress. Optional accounts use an ASP.NET Core service with +Google sign-in. Contributors need clear places to improve learning content, +frontend behavior, or account persistence without coupling them together. + +## Decision + +Keep the Next.js frontend at the repository root and add a .NET 10 solution under `backend/`. +Share original question data through `shared/practice/questions.json`; both runtimes validate +against that bank. + +- **Frontend routes (`src/app/`):** connect URL, metadata, and feature views. The + `/api/platform/*` forwarding layer keeps browser account requests under the public frontend + origin and uses the server-only `DOTNET_API_URL` configuration. +- **Practice feature (`src/features/practice/`):** own TypeScript question selection, answer + evaluation, recommendations, and views. Pure domain code stays independent of React, browser + storage, identity, and payment SDKs. +- **Browser progress (`src/features/progress/`):** validate attempt records, scope local history + to the guest or signed-in account, and coordinate optional sync. Guest practice remains usable + without backend configuration. +- **Backend (`backend/`):** own Google sign-in, authenticated cookie sessions, antiforgery + protection, account persistence, and trusted data validation. Use EF Core with SQLite for + local development and a PostgreSQL option for configured deployments. +- **Shared questions (`shared/practice/`):** maintain one canonical question set. Changes to its + contract require tests in both runtimes. +- **Content pipeline:** retain reviewed catalogue and Markdown publishing for course resources, + guides, and listings. + +Use `/practice` for topic discovery, `/practice/[topic]` for sessions, and `/learn` for the +learner workspace. `/account` provides optional Google account controls. Existing `/dashboard` +course notes and `/games` exercises retain their responsibilities. + +The browser requests session state through `/api/platform/auth/session` and sends its +antiforgery token in `X-CSRF-Token` for mutations. Google credentials stay on the backend. The +public callback `/api/platform/signin-google` is forwarded to ASP.NET Core; `PublicOrigin` +records the frontend origin. Student access comes from authenticated identity rather than a +browser-supplied account ID alone. + +## Current delivery and future payments + +This implementation delivers practice, explanations, browser-saved progress, a learner +workspace, and a backend foundation for Google accounts and synchronized progress. Configured +operators must verify live sign-in and deployment behavior. It does not enable paid plans or +live checkout. + +Razorpay or Paytm is a future provider decision. Billing also requires a defined product offer, +server-verified payment state, trustworthy entitlements, cancellation/support behavior, and +operational readiness. A client-side flag or UI label is not proof of payment. Do not add a +simulated checkout that suggests a student has purchased a real service. + +## Consequences + +- Students can use free practice and browser progress before creating an account. +- Frontend contributors can work without .NET or Google credentials; backend contributors have + a focused solution and test suite. +- Both runtimes agree on the question source and must preserve compatible data contracts. +- Hosting the account service adds API, database, secrets, and operational responsibilities. +- Browser-only progress has device/browser limits; optional accounts add synchronization. +- A feature does not need a new package or service unless a concrete consumer or independent + deployment requirement justifies it. + +Changes to account requirements, shared-data contracts, paid access, or runtime boundaries +should be recorded in a later decision and reflected in the development and architecture guides. diff --git a/docs/deployment.md b/docs/deployment.md index 20a4230..f2d1679 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,14 +4,32 @@ Everything in this file is a **manual step for the site owner**. The code never Vercel settings, and nothing here has been done for you. State of the domains was checked read-only on 2026-10-14. +## Frontend and account service + +Deploy the Next.js frontend with Node 24. Guest practice works on its own. To enable Google +accounts, deploy the [.NET 10 backend](../backend/README.md), run its database migrations, +and set the frontend's server-only `DOTNET_API_URL` to the reachable backend origin. Use +PostgreSQL or durable storage for account data; an ephemeral serverless filesystem is not a +production SQLite volume. Data Protection keys persist in the account database and must be +protected along with it. + +Set the backend's `PublicOrigin` to the exact HTTPS frontend origin and register +`/api/platform/signin-google` in the Google OAuth client. Google secrets +belong in the backend host's secret store. Verify Google consent, callback, cookies, progress +sync, export, and deletion in the configured deployment before opening accounts to students. +Razorpay/Paytm checkout is planned; this release does not accept payments. + +The DNS observations below are dated owner notes. Recheck them before changing a deployment. + ## 1. Environment variables -Set these in Vercel: **Project -> Settings -> Environment Variables**. Neither value is a secret. +Set these in Vercel: **Project -> Settings -> Environment Variables**. Google credentials are configured separately on the backend. | Name | Value | Environments | Notes | | --- | --- | --- | --- | | `YOUTUBE_CHANNEL_ID` | `UC74q2pzSGuoaikYwnavcGMg` | Production (and Preview if you want videos there) | Enables the "Latest videos" section. Verified against the channel feed for @Pro_coder_ji. Leave it unset to hide the section. | | `NEXT_PUBLIC_SITE_URL` | *(do not set)* | - | The site already defaults to `https://procoderji.com`. Only set it to override, e.g. on a staging project. | +| `DOTNET_API_URL` | Your backend origin | Environments with account sync enabled | Server-only. Leave unset for guest-only practice. Local example: `http://localhost:5100`. | Steps: diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..d0bac1e --- /dev/null +++ b/docs/development.md @@ -0,0 +1,178 @@ +# Development + +Start with [Contributing](../CONTRIBUTING.md) for the fork, branch, and pull-request workflow. +The repository contains a Next.js frontend at the root, a .NET backend under `backend/`, and a +shared practice-question bank. You can develop guest practice without running the backend. + +## Frontend setup + +Use Node.js **24** and npm. `.nvmrc` and `.node-version` record the supported Node major used in +CI. Install Google Chrome for browser checks; Microsoft Edge is also supported. + +From the repository root: + +```bash +npm ci +npm run dev +``` + +Open . No environment file or production credentials are needed for +practice and local progress. Use `npm ci` to reproduce `package-lock.json`; include lockfile +changes when updating a dependency. + +## Student flows + +| Route | Purpose | +| --- | --- | +| `/learn` | Learner workspace and saved practice progress. | +| `/practice` | Browse placement-practice topics. | +| `/practice/[topic]` | Answer questions and review explanations. | +| `/games` | Existing short reasoning and memory exercises. | +| `/dashboard` | Engineering course catalogue and notes. | +| `/account` | Optional Google sign-in and progress controls when the backend is configured. | + +Practice works without login. The current browser retains up to 100 completed attempts; clearing +its site data can remove that history. Account progress is separate from guest history, with an +explicit import action. The account page can export visible history. + +## Optional backend and Google accounts + +Install the **.NET 10 SDK** and run these commands from the repository root: + +```bash +dotnet restore backend/ProCoderJi.slnx +dotnet run --project backend/src/ProCoderJi.Api +``` + +The development API listens at . To connect Next.js to it, set this +server-only value in `.env.local` and restart the frontend: + +```dotenv +DOTNET_API_URL=http://localhost:5100 +``` + +The browser uses the same-origin `/api/platform/*` endpoints. It does not connect directly to +the backend origin. Keep the backend's `PublicOrigin` configuration aligned with the public +frontend origin, such as `http://localhost:3000` during development. + +Configure a Google OAuth client through the backend's local user-secrets store: + +```bash +dotnet user-secrets set "Authentication:Google:ClientId" "YOUR-CLIENT-ID" --project backend/src/ProCoderJi.Api +dotnet user-secrets set "Authentication:Google:ClientSecret" "YOUR-CLIENT-SECRET" --project backend/src/ProCoderJi.Api +``` + +The corresponding environment keys are `Authentication__Google__ClientId` and +`Authentication__Google__ClientSecret`. Register the frontend callback +`http://localhost:3000/api/platform/signin-google` with Google for local development. The Next.js +proxy forwards this callback to the backend. Production needs the exact deployed frontend +origin and callback, not the private API origin. + +Google credentials and database connection strings stay in backend user-secrets or the hosting +environment. They do not belong in `NEXT_PUBLIC_` variables or committed configuration. The +backend uses cookie authentication; the session endpoint +`/api/platform/auth/session` returns the current user, configuration status, and an antiforgery +token. Mutating account requests send that token in `X-CSRF-Token`. + +The backend uses EF Core with SQLite for local development. A PostgreSQL provider is available +through `Database__Provider=Postgres` and the server-side `ConnectionStrings__Platform` setting. +See [Backend setup](../backend/README.md) for database provisioning, Google configuration, and +production operating requirements. + +No payment provider is enabled. Razorpay or Paytm is a later integration decision with separate +billing, entitlement, and operational requirements. + +## Frontend environment variables + +Copy `.env.example` to `.env.local` only when enabling optional services; `.env.local` is ignored +by Git. + +| Variable | Purpose | +| --- | --- | +| `YOUTUBE_CHANNEL_ID` | Optional public channel ID for latest-video cards. | +| `NEXT_PUBLIC_SITE_URL` | Optional canonical URL override for a staging deployment. | +| `DOTNET_API_URL` | Optional server-only backend origin used by the Next.js proxy. | + +## Where to make changes + +- `src/app/` connects URLs, metadata, and feature views. Its `/api/platform/*` route forwards + account traffic through the frontend origin. +- `src/features/practice/` owns practice UI and pure TypeScript scoring/selection rules. +- `src/features/progress/` validates attempt records and coordinates local storage and account + synchronization. +- `shared/practice/questions.json` is the canonical question bank consumed by the frontend and + backend. Keep IDs stable when improving wording; use a new ID when a question's meaning changes. +- `backend/src/ProCoderJi.Api/` owns Google sign-in, cookie/antiforgery handling, account progress, + and EF persistence. Backend tests live under `backend/tests/`. +- `src/content/` and `src/data/` publish reviewed notes, guides, and listings; see the + [content guide](content-guide.md). + +Read [ADR 0001](decisions/0001-student-product-boundaries.md) before changing account, storage, +shared-data, or payment boundaries. The [architecture guide](architecture.md) describes the app. + +## Checks + +| Command | Use it for | +| --- | --- | +| `npm run lint` | Frontend code quality and accessibility lint rules. | +| `npm run typecheck` | TypeScript compatibility. | +| `npm test` | Practice rules, progress model/store, content validation, and helpers. | +| `npm run build` | Production frontend compilation and generated routes. | +| `npm run e2e` | Frontend desktop/mobile browser routes and interactions; optional backend integration is described below. | +| `npm run launch-check` | Real production links, metadata, sitemap, and indexing rules. | +| `dotnet test backend/ProCoderJi.slnx --configuration Release` | Backend integration and validation tests. | + +Run checks that exercise the changed behavior. Shared-question changes need both frontend and +backend tests. CI restores and builds the .NET solution before testing it in Release mode. +Backend tests use controlled test authentication; Google credentials and other external secrets +are not required. The live Google callback and production operating setup still need the checks +in [Backend setup](../backend/README.md). + +### Browser checks + +Browser checks build with temporary catalogue/content fixtures and restore them when finished. +They refuse to run if `src/data` or `src/content` has local changes, so content authors should use +unit/content checks and the production crawl while editing those files. CI retains screenshots +for seven days if the site-quality job fails. + +By default, `npm run e2e` checks the frontend without starting the backend. With the .NET 10 SDK +installed, opt into the same full-stack browser mode used by CI: + +```bash +E2E_DOTNET=1 npm run e2e +``` + +In PowerShell: + +```powershell +$env:E2E_DOTNET = "1" +npm run e2e +``` + +This mode builds and starts a real .NET process with an isolated SQLite database, connects the +Next.js proxy, and checks the Google sign-in challenge and public callback URL with test client +configuration. It does not complete external Google consent or token exchange. Those live +provider checks remain part of [Backend setup](../backend/README.md). + +To use Edge instead of Chrome: + +```bash +E2E_CHANNEL=msedge npm run e2e +``` + +In PowerShell: + +```powershell +$env:E2E_CHANNEL = "msedge" +npm run e2e +``` + +## Common setup problems + +- If Node cannot run the TypeScript tests, check `node --version` and switch to Node 24. +- If backend commands cannot find a compatible SDK, check `dotnet --list-sdks` for .NET 10. +- If a browser is not found, install Chrome or use `E2E_CHANNEL=msedge` for an installed Edge. +- If guest practice works but account sync is unavailable, check the running backend, + `DOTNET_API_URL`, `PublicOrigin`, and Google settings in the backend guide. +- If a command fails, include its name, relevant error, runtime version, and browser/device in a + bug report. Remove credentials and personal data from logs before sharing them. diff --git a/docs/images/learning-workspace.png b/docs/images/learning-workspace.png new file mode 100644 index 0000000..a450fc4 Binary files /dev/null and b/docs/images/learning-workspace.png differ diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md new file mode 100644 index 0000000..b8e4d64 --- /dev/null +++ b/docs/product-roadmap.md @@ -0,0 +1,52 @@ +# Student product roadmap + +ProCoderJii starts with a complete learning loop: choose a placement topic, answer questions, +review explanations, and decide what to practise next. Coursework stays available under +`/dashboard`; `/learn` is the learner workspace. Track product work in [#22](https://github.com/procoderji-com/procoderji/issues/22). + +## Available in this change + +- Four topic sets with 24 original introductory questions and answer explanations. +- Self-paced sessions, topic-level progress, and a suggested three-session weekly routine. +- Guest progress on the current device, with export and deletion. +- Optional Google sign-in and private cloud sync through the ASP.NET Core 10 backend. +- Separate feature boundaries and contributor instructions for extending questions and product behavior. + +This is a starter practice bank, not a calibrated placement assessment. Repeated questions count +as practice; accuracy is an aid to learning rather than an employability score. + +## Next: validate usefulness and deepen content + +1. Ask students which placement stages and topics are hardest to prepare for. Observe whether + they can complete a session, understand a missed answer, and choose a useful next activity. +2. Expand the reviewed question bank and worked explanations before adding more topic landing pages. +3. Introduce question versions before changing answer keys or retiring IDs used by saved attempts. +4. Design longer mock sessions, richer review history, and personal study goals from student feedback. +5. Publish original coursework and practical guides through the existing content-review workflow. + +Useful measures include session completion, voluntary return practice, explanation helpfulness, +and correction reports. Choose a privacy-conscious measurement approach explicitly; this release +does not add analytics or track students across sites. + +## Monetization after value is established + +Free topic practice should remain a useful standalone experience. Possible paid additions include +larger reviewed mock-test series, advanced progress analysis, or structured cohort support. These +are hypotheses to validate with students; no price or revenue projection has been established. + +Razorpay and Paytm are the proposed payment providers. Before accepting payments, choose the +provider and define plans, refunds, account deletion, +support, and accessible cancellation. Paid access must be checked on the server. Payment events +need verified signatures, idempotent processing, and a durable audit trail. Never grant access +from a client-side success redirect or expose provider secrets to the browser. + +No checkout, subscription, or paid entitlement is enabled in this change. + +## Grow the architecture when a measured need appears + +- Keep the Next.js frontend and ASP.NET Core backend in one contribution repository while their feature boundaries remain clear. +- Review account isolation, email delivery, backups, monitoring, and abuse limits before public cloud-account rollout. +- Store only learning data the product actually uses; the current workspace retains 100 recent attempts. +- Move question publishing to a database or editorial tool when file review becomes a bottleneck, + preserving stable IDs and a reviewed publication process. +- Add services or workspaces when independent deployment or ownership calls for them. diff --git a/docs/security.md b/docs/security.md index 39ba5a8..0162691 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,16 +1,42 @@ # Security notes -Last reviewed: 2026-10-30. This is a static, content-driven site: no accounts, no database, no -user uploads and no server-side secrets. +Last reviewed: 2026-11-10. Public learning content remains file-based. Practice works anonymously +with browser storage; the optional ASP.NET Core backend adds Google sign-in and private progress sync. There are no user +file uploads or live payment processing. To report a vulnerability privately, follow the repository's [security policy](../SECURITY.md). ## Dependencies -- `npm audit` reported **0 vulnerabilities** on 2026-10-30 after updating Next.js and its matching - ESLint config to 16.4.3. Audit findings change over time; run the command again before a release. +- `npm audit --omit=dev` reported **0 production vulnerabilities** on 2026-11-10. +- The full audit reports nine moderate findings from the existing ESLint toolchain's `minimatch` + dependency ([GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249)). + The affected versions predate the account integration. Track a compatible upstream tooling + update rather than forcing a major override or unrelated ESLint downgrade. Audit findings + change over time; rerun both commands before a release. - `playwright-core` (browser tests) is a dev dependency only. +## Accounts and saved progress + +- Browser data is validated, bounded to 100 attempts, and scoped separately for each account + and the guest workspace. Guest imports require an explicit action. +- The browser stores attempts and their cloud-reset generation in one atomic storage envelope. +- The .NET service validates its authentication cookie and compares the expected account ID. + Same-origin writes, antiforgery tokens, request-size limits, and canonical question validation constrain inputs. +- Google OAuth identifies users by their provider subject, not by matching an email address. + Google secrets and account cookies remain on the server; no bearer tokens are stored in browser storage. +- Database operations scope every query and update to the authenticated account. Transactional + writes perform immutable inserts and generation-based resets. +- A reset rotates the generation atomically so stale devices cannot restore deleted history. + Requests and the database both enforce payload bounds; the database retains 100 recent attempts. +- Private responses are not cached. The Next.js proxy forwards only the learner API routes and required headers. + +See [backend setup](../backend/README.md) for Google OAuth, database, and deployment configuration. +Backend integration tests exercise isolation, antiforgery, reset, and retention behavior. +Google consent-screen configuration, exact callback URLs, HTTPS, and hosted cookie behavior +also need verification in the operator's configured deployment. Persist data-protection keys +and secure the database and key material when scaling backend instances. + ## Notes and guides (content pipeline) Notes are written by the owner (and reviewed contributions), but are treated defensively: @@ -39,6 +65,7 @@ browser tests fail if a new host appears that it does not mention): | `lottie.host` | About-section animation file | home page | | `cdn.jsdelivr.net` | the animation player's WebAssembly file | home page | | `i.ytimg.com` | latest-video thumbnails | only if `YOUTUBE_CHANNEL_ID` is set | +| Google sign-in pages | Google account authorization | when the student chooses Google sign-in | Self-hosting the player's `.wasm` (it is in `node_modules/@lottiefiles/dotlottie-web/dist`) and the animation file would remove the first two; check the animation's licence before copying it. @@ -46,7 +73,7 @@ animation file would remove the first two; check the animation's licence before ## Content-Security-Policy (not enabled yet) Baseline headers are enabled in `next.config.ts` (nosniff, frame deny, referrer policy, -permissions policy). A CSP was **trial-run in report-only mode** (nothing blocked) in Chrome over +permissions policy). Before account integration, a CSP was **trial-run in report-only mode** (nothing blocked) in Chrome over the home page, dashboard, legal pages and the 404 page at desktop and phone size, with the YouTube section on. This policy produced **zero violations**: @@ -70,6 +97,9 @@ Why it is not enforced yet: the policy should be finalised after the advertising decision, then trialled again with `Content-Security-Policy-Report-Only` before enforcing. +Google sign-in uses a top-level redirect; learning API calls stay on the app origin through +the Next.js proxy. Repeat the account flows when evaluating a CSP for the deployed service. + To trial it: add the policy above as a `Content-Security-Policy-Report-Only` header in `next.config.ts`, run the site, and watch the browser console for "violates the following Content Security Policy" messages. @@ -79,4 +109,6 @@ Security Policy" messages. - A contributor with merge rights could still publish misleading content; review pull requests. - Links in notes may point to any https site (external links get `noopener noreferrer`). - The contact form is a `mailto:` link, so spam is handled by the owner's mailbox, not the site. -- No rate limiting or WAF is configured here; that is a hosting-level concern. +- Request bounds are not a distributed rate limiter. Configure provider limits and + infrastructure abuse controls before a public cloud-account rollout. Backend user-secrets + are for development; use the hosting platform's secret store for Google and database credentials. diff --git a/e2e/checks.mjs b/e2e/checks.mjs index bb348e6..c10cc38 100644 --- a/e2e/checks.mjs +++ b/e2e/checks.mjs @@ -4,6 +4,7 @@ import path from "node:path"; const BASE = process.env.BASE || "http://localhost:3150"; const CHANNEL = process.env.E2E_CHANNEL || "chrome"; +const FULLSTACK = process.env.E2E_DOTNET === "1"; const OUT = path.resolve("e2e/.out"); fs.mkdirSync(OUT, { recursive: true }); @@ -12,6 +13,7 @@ const PAGES = [ "/dashboard/btech/cse/sem2", "/dashboard/btech/cse/sem1/qa-subject", "/dashboard/btech/cse/sem1/qa-subject/unit-a", "/dashboard/btech/cse/sem1/qa-subject/unit-b", "/guides", "/guides/qa-guide", "/jobs", "/games", "/games/pattern-sprint", "/games/digit-recall", + "/learn", "/practice", "/practice/quantitative", "/account", "/about", "/privacy", "/terms", "/disclaimer", "/copyright", "/nope/zzz", "/dashboard/btech/year3/sem5/ml", ]; @@ -116,6 +118,39 @@ async function interaction(name, vpName, fn) { } const expectTrue = (v, msg) => { if (!v) throw new Error(msg); }; +if (FULLSTACK) { + await interaction("account: Google challenge and anonymous API boundaries", "desktop", async (page) => { + await page.goto(BASE + "/account", { waitUntil: "networkidle" }); + const googleButton = page.getByRole("button", { name: "Continue with Google", exact: true }); + await googleButton.waitFor({ state: "visible" }); + const googleForm = googleButton.locator("xpath=ancestor::form"); + expectTrue((await googleForm.getAttribute("action")) === "/api/platform/auth/google" && (await googleForm.getAttribute("method")) === "get", "Google sign-in uses a full-page same-origin account challenge"); + expectTrue((await googleForm.locator('input[name="returnTo"]').inputValue()) === "/learn", "Google sign-in returns to the learning workspace"); + + const sessionResponse = await page.request.get(BASE + "/api/platform/auth/session"); + expectTrue(sessionResponse.status() === 200, "anonymous account session returns 200"); + const session = await sessionResponse.json(); + expectTrue(session.configured === true && session.user === null, "fake Google configuration enables sign-in without authenticating a learner"); + expectTrue(typeof session.csrfToken === "string" && session.csrfToken.length > 0, "session exposes the antiforgery request token"); + expectTrue((sessionResponse.headers()["cache-control"] ?? "").includes("no-store"), "account responses are not cached"); + + // Never visit the external identity provider. Inspect only our challenge response. + const challenge = await page.request.get(BASE + "/api/platform/auth/google?returnTo=/learn", { maxRedirects: 0 }); + expectTrue(challenge.status() === 302, `Google challenge returns a redirect (got ${challenge.status()})`); + const location = challenge.headers().location; + expectTrue(Boolean(location), "Google challenge provides an authorization URL"); + const authorization = new URL(location); + expectTrue(authorization.hostname === "accounts.google.com", "challenge targets Google's authorization host"); + expectTrue(authorization.searchParams.get("redirect_uri") === new URL("/api/platform/signin-google", BASE).href, "Google callback points back to the frontend proxy"); + expectTrue(challenge.headersArray().some((header) => header.name.toLowerCase() === "set-cookie" && header.value.includes(".AspNetCore.Correlation.")), "challenge sets its browser correlation cookie"); + + const progress = await page.request.get(BASE + "/api/platform/progress"); + expectTrue(progress.status() === 401, `anonymous progress remains protected (got ${progress.status()})`); + const logout = await page.request.post(BASE + "/api/platform/auth/logout", { headers: { Origin: new URL(BASE).origin }, maxRedirects: 0 }); + expectTrue(logout.status() === 403, `same-origin logout still requires an antiforgery token (got ${logout.status()})`); + }); +} + await interaction("home: mobile menu opens, lists links, closes", "mobile", async (page) => { await page.goto(BASE + "/", { waitUntil: "networkidle" }); const btn = page.getByRole("button", { name: /open menu/i }); @@ -161,6 +196,45 @@ await interaction("games: digit recall accepts an answer", "mobile", async (page expectTrue(await page.getByRole("heading", { name: "Correct!" }).isVisible(), "correct answer feedback appears"); }); +for (const vp of ["desktop", "mobile"]) { + await interaction("practice: guest sessions persist and can be deleted", vp, async (page) => { + await page.goto(BASE + "/practice/reasoning", { waitUntil: "networkidle" }); + await page.getByLabel("Session length").selectOption("3"); + await page.getByRole("button", { name: "Start practice", exact: true }).click(); + for (let index = 0; index < 3; index++) { + await page.getByRole("radio").first().check(); + await page.getByRole("button", { name: index === 2 ? "Finish session" : "Next question", exact: true }).click(); + } + await page.getByRole("heading", { name: "Session complete", exact: true }).waitFor({ state: "visible" }); + const review = page.locator("section").filter({ has: page.getByRole("heading", { name: "Answer review", exact: true }) }); + expectTrue((await review.locator("article").count()) === 3, "every question has an answer review"); + expectTrue((await review.getByText("Correct answer:", { exact: true }).count()) === 3, "review includes every correct answer"); + const explanations = await review.locator("article > p:last-child").allTextContents(); + expectTrue(explanations.length === 3 && explanations.every((text) => text.trim().length > 20), "review includes useful explanations"); + await page.getByRole("link", { name: "Review progress", exact: true }).click(); + await page.waitForURL(/\/learn$/); + const completedSessions = () => page.locator("section").filter({ has: page.getByRole("heading", { name: "Completed sessions", exact: true }) }); + await completedSessions().getByText("1", { exact: true }).waitFor({ state: "visible" }); + await page.reload({ waitUntil: "networkidle" }); + await completedSessions().getByText("1", { exact: true }).waitFor({ state: "visible" }); + + await page.goto(BASE + "/account", { waitUntil: "networkidle" }); + const dataControls = page.locator("section").filter({ has: page.getByRole("heading", { name: "Your progress data", exact: true }) }); + let guestDeletionConfirmed = false; + page.once("dialog", (dialog) => { + guestDeletionConfirmed = dialog.type() === "confirm" && dialog.message().includes("guest practice history"); + return dialog.accept(); + }); + await dataControls.getByRole("button", { name: "Delete progress", exact: true }).click(); + expectTrue(guestDeletionConfirmed, "guest history deletion requires explicit confirmation"); + await page.getByText("Practice history deleted for this workspace.", { exact: true }).waitFor({ state: "visible" }); + await page.goto(BASE + "/learn", { waitUntil: "networkidle" }); + await completedSessions().getByText("0", { exact: true }).waitFor({ state: "visible" }); + await page.reload({ waitUntil: "networkidle" }); + await completedSessions().getByText("0", { exact: true }).waitFor({ state: "visible" }); + }); +} + for (const vp of ["desktop", "mobile"]) { await interaction("hero: pick branch then semester", vp, async (page) => { await page.goto(BASE + "/", { waitUntil: "networkidle" }); diff --git a/e2e/launch.mjs b/e2e/launch.mjs index 6e4abda..c8d3d10 100644 --- a/e2e/launch.mjs +++ b/e2e/launch.mjs @@ -222,7 +222,7 @@ try { } if (home.headers.get("x-powered-by")) bad("/", `framework header is still sent: X-Powered-By: ${home.headers.get("x-powered-by")}`); // Removed template/unused files must not be served any more. - for (const p of ["/next.svg", "/vercel.svg", "/file.svg", "/globe.svg", "/window.svg", "/bg.png"]) { + for (const p of ["/next.svg", "/vercel.svg", "/file.svg", "/globe.svg", "/window.svg", "/bg.png", "/bg.jpg"]) { if ((await fetch(BASE + p)).status !== 404) bad(p, "unused file is still being served"); } diff --git a/e2e/run.mjs b/e2e/run.mjs index 8ee3cef..9b2b64e 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -1,46 +1,150 @@ -// Browser tests: builds the site with temporary synthetic content, drives Chrome over every -// page on desktop and mobile, then restores the real data. Usage: npm run e2e -// Needs Google Chrome installed (set E2E_CHANNEL=msedge to use Edge instead). +// Browser checks use temporary synthetic content and restore it when they exit. +// E2E_DOTNET=1 also builds and starts an isolated ASP.NET Core API. Google +// credentials are fake: checks inspect the challenge redirect without following it. +// Needs Chrome installed (set E2E_CHANNEL=msedge to use Edge instead). import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import process from "node:process"; import * as fixtures from "./fixtures.mjs"; const ROOT = path.resolve(import.meta.dirname, ".."); -const PORT = process.env.E2E_PORT || "3150"; +const PORT = checkedPort(process.env.E2E_PORT || "3150", "E2E_PORT"); +const BASE = `http://localhost:${PORT}`; +const FULLSTACK = process.env.E2E_DOTNET === "1"; +const BACKEND_PORT = checkedPort(process.env.E2E_BACKEND_PORT || "5151", "E2E_BACKEND_PORT"); +const BACKEND_ORIGIN = `http://127.0.0.1:${BACKEND_PORT}`; +const BACKEND_PROJECT = path.join(ROOT, "backend", "src", "ProCoderJi.Api", "ProCoderJi.Api.csproj"); +const BACKEND_DLL = path.join(ROOT, "backend", "src", "ProCoderJi.Api", "bin", "Release", "net10.0", "ProCoderJi.Api.dll"); const NEXT = path.join(ROOT, "node_modules", "next", "dist", "bin", "next"); +const TEMP_ROOT = fs.realpathSync(os.tmpdir()); +const runEnv = { ...process.env, DOTNET_API_URL: FULLSTACK ? BACKEND_ORIGIN : "" }; let server = null; -let restored = false; -const cleanup = () => { - if (restored) return; - restored = true; - if (server?.pid) spawnSync("taskkill", ["/PID", String(server.pid), "/T", "/F"], { stdio: "ignore" }); - if (server?.pid && process.platform !== "win32") try { process.kill(server.pid); } catch {} - fixtures.restore(); -}; -for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { cleanup(); process.exit(130); }); +let backend = null; +let activeCommand = null; +let tempDirectory = null; +let fixturesApplied = false; +let cleaned = false; + +function checkedPort(value, name) { + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new Error(`${name} must be a port from 1 to 65535`); + return value; +} + +function stop(child) { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + } else { + try { process.kill(-child.pid, "SIGKILL"); } + catch { try { child.kill("SIGKILL"); } catch {} } + } +} + +function start(executable, args, env) { + const child = spawn(executable, args, { + cwd: ROOT, env, detached: process.platform !== "win32", windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const capture = (chunk) => { output = (output + chunk.toString()).slice(-6000); }; + child.stdout.on("data", capture); + child.stderr.on("data", capture); + child.on("error", (error) => capture(error.message)); + return { child, logs: () => output }; +} + +function run(executable, args, env = runEnv) { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { + cwd: ROOT, env, detached: process.platform !== "win32", windowsHide: true, stdio: "inherit", + }); + activeCommand = child; + child.once("error", (error) => { if (activeCommand === child) activeCommand = null; reject(error); }); + child.once("exit", (status, signal) => { + if (activeCommand === child) activeCommand = null; + if (status === 0) resolve(); + else reject(new Error(`${path.basename(executable)} ${args[0]} failed (${signal ?? status ?? "unknown exit"})`)); + }); + }); +} + +async function waitUntilReady(service, url, name, attempts = 60) { + for (let index = 0; index < attempts; index++) { + if (service.child.exitCode !== null || !service.child.pid) break; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`${name} did not become ready at ${url}.\n${service.logs()}`); +} + +function cleanup() { + if (cleaned) return; + cleaned = true; + stop(activeCommand); + stop(server?.child); + stop(backend?.child); + try { + if (fixturesApplied) fixtures.restore(); + } finally { + if (tempDirectory) { + // Delete only the uniquely generated directory owned by this invocation. + const target = path.resolve(tempDirectory); + if (path.dirname(target) !== TEMP_ROOT || !path.basename(target).startsWith("procoderji-e2e-")) { + throw new Error("Refusing to remove an unexpected E2E temporary directory"); + } + fs.rmSync(target, { recursive: true, force: true }); + } + } +} + +for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => { + try { cleanup(); } finally { process.exit(130); } +}); let code = 1; try { - fixtures.apply(); // throws (and changes nothing) if real content has uncommitted changes - const build = spawnSync(process.execPath, [NEXT, "build"], { cwd: ROOT, stdio: "inherit" }); - if (build.status !== 0) throw new Error("next build failed"); - - server = spawn(process.execPath, [NEXT, "start", "-p", PORT], { cwd: ROOT, stdio: "ignore" }); - const base = `http://localhost:${PORT}`; - for (let i = 0; i < 40; i++) { - try { if ((await fetch(base)).ok) break; } catch {} - await new Promise((r) => setTimeout(r, 500)); - } - - const checks = spawnSync(process.execPath, [path.join(ROOT, "e2e", "checks.mjs")], { - cwd: ROOT, stdio: "inherit", env: { ...process.env, BASE: base }, - }); - code = checks.status ?? 1; -} catch (e) { - console.error(String(e.message ?? e)); + // If the cleanliness check fails, cleanup must never check out real content. + fixtures.assertClean(); + fixturesApplied = true; + fixtures.apply(); + + if (FULLSTACK) { + if (PORT === BACKEND_PORT) throw new Error("E2E_PORT and E2E_BACKEND_PORT must differ"); + await run("dotnet", ["restore", BACKEND_PROJECT, "--locked-mode"]); + await run("dotnet", ["build", BACKEND_PROJECT, "--configuration", "Release", "--no-restore"]); + tempDirectory = fs.mkdtempSync(path.join(TEMP_ROOT, "procoderji-e2e-")); + const databasePath = path.join(tempDirectory, "platform.db").replaceAll('"', '""'); + backend = start("dotnet", [BACKEND_DLL, "--urls", BACKEND_ORIGIN], { + ...runEnv, + ASPNETCORE_ENVIRONMENT: "Development", + DOTNET_ENVIRONMENT: "Development", + PublicOrigin: BASE, + Database__Provider: "Sqlite", + Database__MigrateOnStartup: "true", + ConnectionStrings__Platform: `Data Source="${databasePath}"`, + Authentication__Google__ClientId: "e2e-client.apps.googleusercontent.com", + Authentication__Google__ClientSecret: "e2e-not-a-real-secret", + Logging__LogLevel__Default: "Warning", + }); + await waitUntilReady(backend, `${BACKEND_ORIGIN}/api/platform/health`, "ASP.NET Core API"); + console.log(`Isolated ASP.NET Core API ready at ${BACKEND_ORIGIN}.`); + } + + await run(process.execPath, [NEXT, "build"]); + server = start(process.execPath, [NEXT, "start", "-p", PORT], runEnv); + await waitUntilReady(server, BASE, "Next.js", 40); + await run(process.execPath, [path.join(ROOT, "e2e", "checks.mjs")], { ...runEnv, BASE }); + code = 0; +} catch (error) { + console.error(String(error.message ?? error)); } finally { - cleanup(); + try { cleanup(); } + catch (error) { console.error(`E2E cleanup failed: ${String(error.message ?? error)}`); code = 1; } } process.exit(code); diff --git a/global.json b/global.json new file mode 100644 index 0000000..d46d21e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/package-lock.json b/package-lock.json index 3d0c39f..204353d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "typescript": "^5" }, "engines": { - "node": ">=20.9.0" + "node": ">=24 <25" } }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index 05ec259..085fb4e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "engines": { - "node": ">=20.9.0" + "node": ">=24 <25" }, "scripts": { "dev": "next dev", diff --git a/shared/practice/questions.json b/shared/practice/questions.json new file mode 100644 index 0000000..09f47cc --- /dev/null +++ b/shared/practice/questions.json @@ -0,0 +1,626 @@ +[ + { + "id": "quant-discount-price", + "topicId": "quantitative", + "difficulty": "easy", + "prompt": "A backpack marked at Rs 800 is sold at a 15% discount. What is its selling price?", + "options": [ + { + "id": "a", + "text": "Rs 600" + }, + { + "id": "b", + "text": "Rs 680" + }, + { + "id": "c", + "text": "Rs 720" + }, + { + "id": "d", + "text": "Rs 785" + } + ], + "answerId": "b", + "explanation": "15% of 800 is 120. Subtract the discount: 800 - 120 = Rs 680." + }, + { + "id": "quant-ratio-share", + "topicId": "quantitative", + "difficulty": "easy", + "prompt": "Two groups have students in the ratio 3:5. There are 64 students in total. How many are in the smaller group?", + "options": [ + { + "id": "a", + "text": "16" + }, + { + "id": "b", + "text": "20" + }, + { + "id": "c", + "text": "24" + }, + { + "id": "d", + "text": "40" + } + ], + "answerId": "c", + "explanation": "There are 3 + 5 = 8 equal parts. Each part is 64 / 8 = 8 students, so the smaller group has 3 x 8 = 24 students." + }, + { + "id": "quant-arithmetic-mean", + "topicId": "quantitative", + "difficulty": "easy", + "prompt": "What is the arithmetic mean of 12, 16, 20, and 24?", + "options": [ + { + "id": "a", + "text": "16" + }, + { + "id": "b", + "text": "18" + }, + { + "id": "c", + "text": "20" + }, + { + "id": "d", + "text": "22" + } + ], + "answerId": "b", + "explanation": "Add the four values to get 72, then divide by 4. The mean is 18." + }, + { + "id": "quant-average-speed", + "topicId": "quantitative", + "difficulty": "easy", + "prompt": "A bus covers 150 km in 3 hours. What is its average speed over the trip?", + "options": [ + { + "id": "a", + "text": "30 km/h" + }, + { + "id": "b", + "text": "45 km/h" + }, + { + "id": "c", + "text": "50 km/h" + }, + { + "id": "d", + "text": "60 km/h" + } + ], + "answerId": "c", + "explanation": "Average speed is total distance divided by total time: 150 / 3 = 50 km/h." + }, + { + "id": "quant-simple-interest", + "topicId": "quantitative", + "difficulty": "medium", + "prompt": "What is the simple interest on Rs 5,000 at 8% per year for 2 years?", + "options": [ + { + "id": "a", + "text": "Rs 400" + }, + { + "id": "b", + "text": "Rs 800" + }, + { + "id": "c", + "text": "Rs 832" + }, + { + "id": "d", + "text": "Rs 5,800" + } + ], + "answerId": "b", + "explanation": "Simple interest = principal x rate x time / 100 = 5,000 x 8 x 2 / 100 = Rs 800. Rs 5,800 is the total amount, not the interest." + }, + { + "id": "quant-equal-pump-rates", + "topicId": "quantitative", + "difficulty": "medium", + "prompt": "Six identical pumps fill a tank in 4 hours. At the same constant rate per pump, how long would 3 pumps take to fill the same empty tank?", + "options": [ + { + "id": "a", + "text": "2 hours" + }, + { + "id": "b", + "text": "4 hours" + }, + { + "id": "c", + "text": "6 hours" + }, + { + "id": "d", + "text": "8 hours" + } + ], + "answerId": "d", + "explanation": "The tank requires 6 x 4 = 24 pump-hours of work. With 3 identical pumps, the time is 24 / 3 = 8 hours." + }, + { + "id": "reasoning-constant-gap", + "topicId": "reasoning", + "difficulty": "easy", + "prompt": "What comes next in the sequence 4, 9, 14, 19, ...?", + "options": [ + { + "id": "a", + "text": "22" + }, + { + "id": "b", + "text": "23" + }, + { + "id": "c", + "text": "24" + }, + { + "id": "d", + "text": "25" + } + ], + "answerId": "c", + "explanation": "Each term increases by 5. Adding 5 to 19 gives 24." + }, + { + "id": "reasoning-doubling", + "topicId": "reasoning", + "difficulty": "easy", + "prompt": "What comes next in the sequence 3, 6, 12, 24, ...?", + "options": [ + { + "id": "a", + "text": "30" + }, + { + "id": "b", + "text": "36" + }, + { + "id": "c", + "text": "42" + }, + { + "id": "d", + "text": "48" + } + ], + "answerId": "d", + "explanation": "Each term is twice the previous term, so the next term is 24 x 2 = 48." + }, + { + "id": "reasoning-notebook-deduction", + "topicId": "reasoning", + "difficulty": "medium", + "prompt": "All notebooks are stationery items. No stationery item is edible. Which statement must follow?", + "options": [ + { + "id": "a", + "text": "No notebook is edible." + }, + { + "id": "b", + "text": "All stationery items are notebooks." + }, + { + "id": "c", + "text": "Some notebooks are edible." + }, + { + "id": "d", + "text": "Everything that is not edible is a notebook." + } + ], + "answerId": "a", + "explanation": "Every notebook belongs to the stationery group, and every member of that group is not edible. The other statements reverse or contradict the given relationships." + }, + { + "id": "reasoning-shortest-distance", + "topicId": "reasoning", + "difficulty": "medium", + "prompt": "A student walks 4 km north and then 3 km east. What is the straight-line distance from the starting point?", + "options": [ + { + "id": "a", + "text": "1 km" + }, + { + "id": "b", + "text": "5 km" + }, + { + "id": "c", + "text": "7 km" + }, + { + "id": "d", + "text": "12 km" + } + ], + "answerId": "b", + "explanation": "The two legs are perpendicular. By the Pythagorean theorem, the straight-line distance is the square root of (4 squared + 3 squared), which is 5 km." + }, + { + "id": "reasoning-three-person-order", + "topicId": "reasoning", + "difficulty": "easy", + "prompt": "A, B, and C stand in a row facing north. A is to the left of B, and B is to the left of C. Who is at the left end?", + "options": [ + { + "id": "a", + "text": "A" + }, + { + "id": "b", + "text": "B" + }, + { + "id": "c", + "text": "C" + }, + { + "id": "d", + "text": "Cannot be determined" + } + ], + "answerId": "a", + "explanation": "The constraints give the order A, B, C from left to right, so A is at the left end." + }, + { + "id": "reasoning-letter-shift", + "topicId": "reasoning", + "difficulty": "medium", + "prompt": "A code replaces each letter with the next letter of the alphabet, so MAP becomes NBQ. How is CAR written?", + "options": [ + { + "id": "a", + "text": "DBS" + }, + { + "id": "b", + "text": "BZQ" + }, + { + "id": "c", + "text": "DCS" + }, + { + "id": "d", + "text": "CBR" + } + ], + "answerId": "a", + "explanation": "Shift C to D, A to B, and R to S. CAR becomes DBS." + }, + { + "id": "verbal-neither-agreement", + "topicId": "verbal", + "difficulty": "medium", + "prompt": "In formal written English, choose the correct word: Neither of the two proposals ___ ready.", + "options": [ + { + "id": "a", + "text": "are" + }, + { + "id": "b", + "text": "were" + }, + { + "id": "c", + "text": "is" + }, + { + "id": "d", + "text": "have" + } + ], + "answerId": "c", + "explanation": "In formal subject-verb agreement, 'neither' is singular here, so it takes the singular verb 'is'." + }, + { + "id": "verbal-concise-meaning", + "topicId": "verbal", + "difficulty": "easy", + "prompt": "A teacher asks for a concise explanation. Which description best matches 'concise'?", + "options": [ + { + "id": "a", + "text": "Long and repetitive" + }, + { + "id": "b", + "text": "Brief and clear" + }, + { + "id": "c", + "text": "Uncertain and hesitant" + }, + { + "id": "d", + "text": "Technical and difficult" + } + ], + "answerId": "b", + "explanation": "A concise explanation expresses the important information clearly in few words." + }, + { + "id": "verbal-duration-preposition", + "topicId": "verbal", + "difficulty": "easy", + "prompt": "Choose the correct word: She has been studying ___ two hours.", + "options": [ + { + "id": "a", + "text": "since" + }, + { + "id": "b", + "text": "from" + }, + { + "id": "c", + "text": "during" + }, + { + "id": "d", + "text": "for" + } + ], + "answerId": "d", + "explanation": "Use 'for' with a duration such as two hours. Use 'since' with a starting point, such as 9 a.m." + }, + { + "id": "verbal-active-voice", + "topicId": "verbal", + "difficulty": "medium", + "prompt": "Which active-voice sentence preserves the meaning of 'The report was submitted by Nila'?", + "options": [ + { + "id": "a", + "text": "Nila submitted the report." + }, + { + "id": "b", + "text": "Nila will submit the report." + }, + { + "id": "c", + "text": "The report submitted Nila." + }, + { + "id": "d", + "text": "Nila is submitting the report." + } + ], + "answerId": "a", + "explanation": "The original sentence describes a completed past action. 'Nila submitted the report' makes Nila the subject and keeps the same tense and meaning." + }, + { + "id": "verbal-attendee-inference", + "topicId": "verbal", + "difficulty": "medium", + "prompt": "Every attendee at a workshop registered in advance. Ravi attended that workshop. Which conclusion follows?", + "options": [ + { + "id": "a", + "text": "Ravi organized the workshop." + }, + { + "id": "b", + "text": "Ravi registered in advance." + }, + { + "id": "c", + "text": "Ravi was the first to register." + }, + { + "id": "d", + "text": "Everyone who registered attended." + } + ], + "answerId": "b", + "explanation": "Ravi is an attendee, and the statement says every attendee registered in advance. It gives no information about registration order or people who registered but did not attend." + }, + { + "id": "verbal-ambiguous-meaning", + "topicId": "verbal", + "difficulty": "easy", + "prompt": "If an instruction is ambiguous, what does that mean?", + "options": [ + { + "id": "a", + "text": "It is always incorrect." + }, + { + "id": "b", + "text": "It is unusually short." + }, + { + "id": "c", + "text": "It can be understood in more than one way." + }, + { + "id": "d", + "text": "It must be followed immediately." + } + ], + "answerId": "c", + "explanation": "An ambiguous statement has more than one possible interpretation. Clear wording helps remove the uncertainty." + }, + { + "id": "programming-number-type", + "topicId": "programming", + "difficulty": "easy", + "prompt": "In JavaScript, what string does typeof 42 return?", + "options": [ + { + "id": "a", + "text": "\"integer\"" + }, + { + "id": "b", + "text": "\"number\"" + }, + { + "id": "c", + "text": "\"string\"" + }, + { + "id": "d", + "text": "\"float\"" + } + ], + "answerId": "b", + "explanation": "JavaScript represents ordinary numeric values such as 42 using the number type. typeof returns the string 'number'." + }, + { + "id": "programming-array-length", + "topicId": "programming", + "difficulty": "easy", + "prompt": "In JavaScript, what is the value of [2, 4, 6].length?", + "options": [ + { + "id": "a", + "text": "2" + }, + { + "id": "b", + "text": "3" + }, + { + "id": "c", + "text": "6" + }, + { + "id": "d", + "text": "12" + } + ], + "answerId": "b", + "explanation": "The array contains three elements. Its length is 3, independently of the values stored in those elements." + }, + { + "id": "programming-loop-sum", + "topicId": "programming", + "difficulty": "medium", + "prompt": "A program starts total at 0, then adds each integer from 1 through 4, inclusive, once. What is total at the end?", + "options": [ + { + "id": "a", + "text": "4" + }, + { + "id": "b", + "text": "6" + }, + { + "id": "c", + "text": "10" + }, + { + "id": "d", + "text": "16" + } + ], + "answerId": "c", + "explanation": "The loop adds 1 + 2 + 3 + 4. The final total is 10; 'inclusive' means that 4 is included." + }, + { + "id": "programming-binary-search", + "topicId": "programming", + "difficulty": "medium", + "prompt": "For n sorted items, binary search repeatedly halves the remaining search range. What is its worst-case time complexity?", + "options": [ + { + "id": "a", + "text": "O(1)" + }, + { + "id": "b", + "text": "O(log n)" + }, + { + "id": "c", + "text": "O(n)" + }, + { + "id": "d", + "text": "O(n squared)" + } + ], + "answerId": "b", + "explanation": "Each comparison removes roughly half of the remaining candidates. The number of halvings needed grows logarithmically with n, so the worst case is O(log n)." + }, + { + "id": "programming-value-copy", + "topicId": "programming", + "difficulty": "medium", + "prompt": "In JavaScript: let x = 5; const y = x; x = 8; What is the value of y afterward?", + "options": [ + { + "id": "a", + "text": "5" + }, + { + "id": "b", + "text": "8" + }, + { + "id": "c", + "text": "undefined" + }, + { + "id": "d", + "text": "The reassignment causes an error." + } + ], + "answerId": "a", + "explanation": "The numeric value 5 is copied into y when it is assigned. Reassigning the separate variable x does not change y. x was declared with let, so that reassignment is allowed." + }, + { + "id": "programming-stack-order", + "topicId": "programming", + "difficulty": "easy", + "prompt": "An empty stack receives push(10), then push(20), then pop(). Which value does pop() return?", + "options": [ + { + "id": "a", + "text": "10" + }, + { + "id": "b", + "text": "20" + }, + { + "id": "c", + "text": "30" + }, + { + "id": "d", + "text": "The stack is empty, so it returns nothing." + } + ], + "answerId": "b", + "explanation": "A stack uses last-in, first-out order. The last value pushed was 20, so it is the first value popped." + } +] diff --git a/src/app/(student)/account/page.tsx b/src/app/(student)/account/page.tsx new file mode 100644 index 0000000..6a2fed0 --- /dev/null +++ b/src/app/(student)/account/page.tsx @@ -0,0 +1,17 @@ +import { AccountPanel } from "@/features/account/account-panel"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata = pageMetadata({ + title: "Account and Learning Data", + description: + "Manage your learning account, sync placement practice across devices, and export or delete your practice history.", + path: "/account", + noindex: true, +}); +export default async function AccountPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { + return ; +} diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx new file mode 100644 index 0000000..09352b8 --- /dev/null +++ b/src/app/(student)/layout.tsx @@ -0,0 +1,14 @@ +import { ProgressProvider } from "@/features/progress/progress-provider"; +import { LearningShell } from "@/features/practice/client/learning-shell"; + +export default function StudentLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/(student)/learn/page.tsx b/src/app/(student)/learn/page.tsx new file mode 100644 index 0000000..189c836 --- /dev/null +++ b/src/app/(student)/learn/page.tsx @@ -0,0 +1,14 @@ +import { LearnerDashboard } from "@/features/practice/client/learner-dashboard"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata = pageMetadata({ + title: "My Learning", + description: + "Review your placement practice, choose your next topic, and build a manageable study routine with ProCoderJii.", + path: "/learn", + noindex: true, +}); + +export default function LearnPage() { + return ; +} diff --git a/src/app/(student)/practice/[topic]/page.tsx b/src/app/(student)/practice/[topic]/page.tsx new file mode 100644 index 0000000..78a8d3e --- /dev/null +++ b/src/app/(student)/practice/[topic]/page.tsx @@ -0,0 +1,52 @@ +import { notFound } from "next/navigation"; +import { + getPracticeTopic, + isTopicId, + PRACTICE_TOPICS, +} from "@/features/practice/domain"; +import { PracticeSession } from "@/features/practice/client/practice-session"; +import { pageMetadata } from "@/lib/seo"; +import { TOPIC_GUIDES } from "@/features/practice/data/guides"; + +type Props = { params: Promise<{ topic: string }> }; +export const generateStaticParams = () => + PRACTICE_TOPICS.map(({ id }) => ({ topic: id })); +export async function generateMetadata({ params }: Props) { + const id = (await params).topic; + const topic = isTopicId(id) ? getPracticeTopic(id) : undefined; + if (!topic) return {}; + return pageMetadata({ + title: `${topic.title} Practice`, + description: `${topic.description} Work through original practice questions and review a clear explanation for every answer.`, + path: `/practice/${topic.id}`, + }); +} +export default async function TopicPage({ params }: Props) { + const id = (await params).topic; + const topic = isTopicId(id) ? getPracticeTopic(id) : undefined; + if (!topic) notFound(); + const guide = TOPIC_GUIDES[topic.id]; + return ( + <> + +
+

+ A useful way to practise {topic.title.toLowerCase()} +

+

+ {guide.introduction} +

+
    + {guide.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+

A worked example

+

{guide.example}

+
+ + ); +} diff --git a/src/app/(student)/practice/page.tsx b/src/app/(student)/practice/page.tsx new file mode 100644 index 0000000..e9db4db --- /dev/null +++ b/src/app/(student)/practice/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { ArrowRight, CheckCircle2, Clock, Target } from "lucide-react"; +import { PRACTICE_TOPICS, QUESTION_BANK } from "@/features/practice/domain"; +import { secondaryAction } from "@/features/practice/client/styles"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata = pageMetadata({ + title: "Campus Placement Practice", + description: + "Practise quantitative aptitude, logical reasoning, verbal ability, and programming fundamentals with original questions and answer explanations.", + path: "/practice", +}); + +export default function PracticePage() { + return ( + <> +

+ Prepare with purpose +

+

+ Campus placement practice +

+

+ Work through short topic sessions. Understand why an answer works, + review mistakes, and use your own progress to decide what comes next. +

+
+ + + + + + +
+
+ {PRACTICE_TOPICS.map((topic, index) => ( +
+ + 0{index + 1} + +

{topic.title}

+

+ {topic.description} +

+

+ {QUESTION_BANK.filter((q) => q.topicId === topic.id).length}{" "} + questions · Easy & medium · Explanations included +

+ + Practise {topic.title} +
+ ))} +
+
+

Learn from every attempt

+

+ These starter sets build familiarity with common reasoning and + aptitude skills. They are independent learning exercises, and the + small question bank is not a calibrated employer assessment. Use + explanations alongside your course material and interview preparation. +

+ + Try a warm-up game{" "} +
+ + ); +} diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts new file mode 100644 index 0000000..be1961a --- /dev/null +++ b/src/app/api/platform/[...path]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BACKEND_PATHS, parseBackendOrigin } from "@/features/account/config"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +const privateHeaders = { + "Cache-Control": "private, no-store", + Vary: "Cookie, Origin", +}; +const error = (message: string, status: number) => + NextResponse.json({ error: message }, { status, headers: privateHeaders }); +const LIMIT = 256 * 1024; + +async function proxy( + request: NextRequest, + context: { params: Promise<{ path: string[] }> }, +) { + const path = (await context.params).path.join("/"); + if (!BACKEND_PATHS.has(path)) return error("Unknown learner endpoint.", 404); + const backend = parseBackendOrigin(process.env.DOTNET_API_URL); + if (!backend) { + if (path === "auth/session" && request.method === "GET") + return NextResponse.json( + { configured: false, user: null, csrfToken: null }, + { headers: privateHeaders }, + ); + return error( + "Account sync is not enabled. You can continue practising on this device.", + 503, + ); + } + const headers = new Headers(); + for (const name of [ + "cookie", + "origin", + "content-type", + "x-account-id", + "x-csrf-token", + ]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + let body: Uint8Array | undefined; + if (request.method !== "GET") { + if (request.headers.get("origin") !== request.nextUrl.origin) + return error("Use the current site to update your account.", 403); + if (Number(request.headers.get("content-length")) > LIMIT) + return error("The request is too large.", 413); + const reader = request.body?.getReader(); + if (reader) { + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > LIMIT) { + await reader.cancel(); + return error("The request is too large.", 413); + } + chunks.push(chunk.value); + } + body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + } catch { + return error("The request body could not be read.", 400); + } finally { + reader.releaseLock(); + } + } + } + try { + const upstream = await fetch( + `${backend}/api/platform/${path}${request.nextUrl.search}`, + { + method: request.method, + headers, + body: body as BodyInit | undefined, + redirect: "manual", + cache: "no-store", + signal: AbortSignal.timeout(20_000), + }, + ); + const responseHeaders = new Headers(privateHeaders); + for (const name of ["content-type", "location"]) { + const value = upstream.headers.get(name); + if (value) responseHeaders.set(name, value); + } + for (const cookie of upstream.headers.getSetCookie()) + responseHeaders.append("set-cookie", cookie); + return new Response(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); + } catch { + return error( + "The account service is temporarily unavailable. Your browser progress is still available.", + 503, + ); + } +} + +export const GET = proxy; +export const POST = proxy; +export const DELETE = proxy; diff --git a/src/app/copyright/page.tsx b/src/app/copyright/page.tsx index 63adb80..60ab372 100644 --- a/src/app/copyright/page.tsx +++ b/src/app/copyright/page.tsx @@ -1,4 +1,4 @@ -import { EmailLink, Fill, H2, LegalLayout, P, UL, legalMetadata } from "@/components/legal/legal-layout"; +import { EmailLink, H2, LegalLayout, P, UL, legalMetadata } from "@/components/legal/legal-layout"; import { LEGAL } from "@/lib/legal"; export const metadata = legalMetadata( @@ -12,8 +12,9 @@ export default function CopyrightPage() {

Our content

- Unless a page says otherwise, the original notes and other content on this site are the copyright of{" "} - , all rights reserved. See the Terms of Use for what you may do with it. + Source code and content in our public repository follow its MIT License unless a file carries a + separate notice. Third-party material retains its own copyright and licensing terms. See the Terms + of Use and the repository's LICENSE and THIRD_PARTY_NOTICES files for reuse information.

Respecting other people's work

diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 97f7763..ab33597 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,5 +1,4 @@ import { DashboardClientLayout } from "./dashboard-layout-client"; -import Image from "next/image"; export default function DashboardLayout({ children, @@ -8,19 +7,10 @@ export default function DashboardLayout({ }) { return ( <> - {/* Background Image with Overlay */} -
- -
-
-
+ {/* Desktop Menu */} -
+
+ Placement practice + My learning About Games Contact @@ -48,7 +50,7 @@ export function Navbar() { {/* Mobile Menu Button */} + + +
+

+ {syncError || + (syncStatus === "synced" + ? "Your progress is synced." + : syncStatus === "syncing" + ? "Syncing your learning history…" + : "Ready to sync.")} +

+ + ) : accountConfigured ? ( + <> +

+ Continue with your Google account +

+

+ Google confirms your identity. Your learning account uses your + basic profile and email; ProCoderJii does not receive your + Google password. +

+
+ + +
+

+ By continuing, you agree to the{" "} + + Terms + {" "} + and acknowledge the{" "} + + Privacy Policy + + . Guest history is imported only when you choose. +

+ + ) : ( + <> +

+ Your browser is your workspace +

+

+ Google sign-in and account sync are unavailable right now. + Your completed sessions save in this browser, and all practice + remains available. +

+ + Continue practising + + + )} + +
+

Your progress data

+

+ {attempts.length} saved sessions in this workspace. Export your + visible history, or delete progress for{" "} + {user + ? "this account and its local workspace" + : "this browser’s guest workspace"} + .{" "} + {user + ? "Deleting practice progress does not delete your sign-in account." + : "Clearing your browser’s storage also removes guest progress."} +

+
+ + +
+ {user && ( +

+ To request deletion of your sign-in account, email{" "} + + {CONTACT_EMAIL} + {" "} + from your account email. +

+ )} +
+ + )} + {message && ( +

+ {message} +

+ )} + {error && ( +

+ {error} +

+ )} + + ); +} diff --git a/src/features/account/client.ts b/src/features/account/client.ts new file mode 100644 index 0000000..c579b24 --- /dev/null +++ b/src/features/account/client.ts @@ -0,0 +1,46 @@ +"use client"; + +export interface LearnerUser { + id: string; + email: string | null; + name: string | null; +} +export interface AccountSession { + configured: boolean; + user: LearnerUser | null; + csrfToken: string | null; +} + +export async function readAccountSession(): Promise { + const response = await fetch("/api/platform/auth/session", { + cache: "no-store", + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) throw new Error("Account service is unavailable."); + const body = await response.json(); + if ( + typeof body.configured !== "boolean" || + (body.user !== null && (!body.user || typeof body.user.id !== "string")) + ) + throw new Error("Invalid account response."); + return { + configured: body.configured, + user: body.user + ? { + id: body.user.id, + email: typeof body.user.email === "string" ? body.user.email : null, + name: typeof body.user.name === "string" ? body.user.name : null, + } + : null, + csrfToken: typeof body.csrfToken === "string" ? body.csrfToken : null, + }; +} + +export function announceAccountChange() { + try { + window.localStorage.setItem("procoderji:auth-change", String(Date.now())); + } catch { + /* Storage is optional. */ + } + window.dispatchEvent(new Event("procoderji-auth-change")); +} diff --git a/src/features/account/config.test.ts b/src/features/account/config.test.ts new file mode 100644 index 0000000..e8e6ace --- /dev/null +++ b/src/features/account/config.test.ts @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { BACKEND_PATHS, parseBackendOrigin } from "./config.ts"; + +test("backend proxy accepts explicit root origins for local and hosted .NET services", () => { + assert.equal( + parseBackendOrigin("http://localhost:5100"), + "http://localhost:5100", + ); + assert.equal( + parseBackendOrigin("http://backend:8080/"), + "http://backend:8080", + ); + assert.equal( + parseBackendOrigin("https://api.example.org"), + "https://api.example.org", + ); +}); +test("backend proxy configuration rejects credentials, URL tricks and non-root destinations", () => { + for (const value of [ + undefined, + "", + " localhost", + "javascript:alert(1)", + "https://user:secret@example.org", + "https://example.org/api", + "https://example.org?token=x", + "https://example.org#x", + "https://example.org\\evil", + "file:///tmp/private", + ]) + assert.equal(parseBackendOrigin(value), null); +}); +test("backend proxy exposes only the learner API paths", () => { + assert.ok(BACKEND_PATHS.has("auth/google")); + assert.ok(BACKEND_PATHS.has("progress")); + for (const path of [ + "admin", + "../secrets", + "auth/unknown", + "billing/checkout", + ]) + assert.ok(!BACKEND_PATHS.has(path)); +}); diff --git a/src/features/account/config.ts b/src/features/account/config.ts new file mode 100644 index 0000000..5e5f01d --- /dev/null +++ b/src/features/account/config.ts @@ -0,0 +1,27 @@ +/** Trusted server configuration only. Browser input never selects an upstream host. */ +export function parseBackendOrigin(value: string | undefined): string | null { + if (!value || value !== value.trim() || /[\\\s]/.test(value)) return null; + try { + const url = new URL(value); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== "/" + ) + return null; + return url.origin; + } catch { + return null; + } +} + +export const BACKEND_PATHS = new Set([ + "auth/session", + "auth/google", + "auth/logout", + "signin-google", + "progress", +]); diff --git a/src/features/practice/client/learner-dashboard.tsx b/src/features/practice/client/learner-dashboard.tsx new file mode 100644 index 0000000..c3ff9a4 --- /dev/null +++ b/src/features/practice/client/learner-dashboard.tsx @@ -0,0 +1,270 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { + ArrowRight, + BookOpen, + CalendarDays, + CheckCircle2, + Target, +} from "lucide-react"; +import { + QUESTION_BANK, + PRACTICE_TOPICS, + combineTopicSummaries, + gradeSession, + recommendNextPractice, +} from "../domain"; +import { useProgress } from "@/features/progress/progress-provider"; +import { primaryAction, secondaryAction } from "./styles"; + +export function LearnerDashboard() { + const { attempts, ready, user, syncStatus, syncError } = useProgress(); + const [now] = useState(() => Date.now()); + const sessions = attempts.map((attempt) => ({ + attempt, + summary: gradeSession( + attempt.questionIds.flatMap((id) => + QUESTION_BANK.filter((q) => q.id === id), + ), + attempt.responses, + ), + })); + const topics = combineTopicSummaries( + sessions.flatMap(({ summary }) => summary.topics), + ); + const recommendation = recommendNextPractice(topics); + const nextTopic = PRACTICE_TOPICS.find( + (topic) => topic.id === recommendation.topicId, + )!; + const answered = sessions.reduce( + (sum, { summary }) => sum + summary.answered, + 0, + ); + const correct = sessions.reduce( + (sum, { summary }) => sum + summary.correct, + 0, + ); + const weekly = attempts.filter( + (attempt) => now - Date.parse(attempt.completedAt) < 7 * 86400000, + ).length; + return ( + <> +
+
+

+ Your learning workspace +

+

+ Build skills. Keep moving. +

+

+ A little focused practice today. A clearer idea of what to work on + tomorrow. +

+
+ + Browse practice
+ {!ready ? ( +

+ Loading your learning progress… +

+ ) : ( + <> +
+ {[ + { + label: "Completed sessions", + value: String(attempts.length), + detail: "Your most recent 100 sessions", + icon: CheckCircle2, + }, + { + label: "Questions answered", + value: String(answered), + detail: "Every attempt is another step", + icon: BookOpen, + }, + { + label: "Practice accuracy", + value: answered + ? `${Math.round((correct / answered) * 100)}%` + : "—", + detail: answered + ? "Based on answered questions" + : "Complete a session to get started", + icon: Target, + }, + ].map(({ label, value, detail, icon: Icon }) => ( +
+
+

{label}

+
+

+ {value} +

+

{detail}

+
+ ))} +
+
+

+ Your next step +

+

{nextTopic.title}

+

+ {recommendation.message} +

+ + Start a short session{" "} +
+
+
+

Your skill picture

+

+ Practice accuracy helps you choose a next topic. It isn’t a + placement readiness score. +

+
+ {PRACTICE_TOPICS.map((topic) => { + const result = topics.find( + (item) => item.topicId === topic.id, + ); + return ( +
+
+ + {topic.title} + + + {result?.answered + ? `${result.accuracyPercent}% · ${result.answered} answered` + : "Not started"} + +
+ + ); + })} +
+
+
+
+
+

+ Suggested goal: three short sessions over seven days. +

+

+ {Math.min(weekly, 3)}{" "} + + of 3 sessions + +

+
    +
  1. 1. Practise your recommended topic.
  2. +
  3. 2. Review explanations for missed answers.
  4. +
  5. 3. Try a different topic to broaden your practice.
  6. +
+

+ You choose the pace. Repeated questions count as practice, so + accuracy is a learning aid rather than a benchmark. +

+
+
+
+
+

Recent sessions

+ + Manage my data + +
+ {sessions.length ? ( +
    + {sessions.slice(0, 5).map(({ attempt, summary }) => ( +
  • +
    +

    + { + PRACTICE_TOPICS.find( + (topic) => topic.id === attempt.topicId, + )?.title + } +

    +

    + {new Date(attempt.completedAt).toLocaleDateString( + "en-IN", + { day: "numeric", month: "short", year: "numeric" }, + )}{" "} + · {summary.answered} answered +

    +
    +

    + {summary.correct}/{summary.totalQuestions} correct +

    +
  • + ))} +
+ ) : ( +
+

Your first session starts here

+

+ Choose a topic, try five questions, and review each + explanation. Your results will appear here. +

+ + Choose a topic{" "} +
+ )} +
+

+ {user + ? syncStatus === "synced" + ? "Your progress is synced to your account." + : syncError || + "Progress saves on this device while cloud sync catches up." + : "Progress is saved in this browser. Clearing browser data removes guest progress."} +

+ + )} + + ); +} diff --git a/src/features/practice/client/learning-shell.tsx b/src/features/practice/client/learning-shell.tsx new file mode 100644 index 0000000..5426a9c --- /dev/null +++ b/src/features/practice/client/learning-shell.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + ArrowUpRight, + BookOpen, + LayoutDashboard, + Puzzle, + Target, + UserRound, +} from "lucide-react"; +import { useProgress } from "@/features/progress/progress-provider"; + +const navigation = [ + { href: "/learn", label: "My learning", icon: LayoutDashboard }, + { href: "/practice", label: "Placement practice", icon: Target }, + { href: "/games", label: "Warm-up games", icon: Puzzle }, + { href: "/dashboard", label: "Course notes", icon: BookOpen }, + { href: "/account", label: "Account & data", icon: UserRound }, +]; + +export function LearningShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { user, accountConfigured, syncStatus, storageAvailable } = + useProgress(); + return ( +
+
+
+ + + + ProCoderJii Learn + + +
+ + Free practice beta + + +
+
+
+
+ +
+ {!storageAvailable && ( +

+ Browser storage is unavailable. You can practise, but progress on + this device may be lost when you leave. +

+ )} + {children} +
+ Independent practice. No hiring guarantees. + + Privacy + + + Terms + + + Contribute on GitHub + +
+
+
+
+ ); +} diff --git a/src/features/practice/client/practice-session.tsx b/src/features/practice/client/practice-session.tsx new file mode 100644 index 0000000..2f8af00 --- /dev/null +++ b/src/features/practice/client/practice-session.tsx @@ -0,0 +1,388 @@ +"use client"; + +import Link from "next/link"; +import { useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, CheckCircle2, RotateCcw } from "lucide-react"; +import { + getPracticeTopic, + gradeSession, + selectPracticeQuestions, + QUESTION_BANK, + type Difficulty, + type OptionId, + type PracticeQuestion, + type TopicId, +} from "../domain"; +import { useProgress } from "@/features/progress/progress-provider"; +import type { AttemptRecord } from "@/features/progress/model"; +import { primaryAction, secondaryAction } from "./styles"; + +export function PracticeSession({ topicId }: { topicId: TopicId }) { + const topic = getPracticeTopic(topicId)!; + const questionCount = QUESTION_BANK.filter( + (question) => question.topicId === topicId, + ).length; + const { ready, saveAttempt, user, storageAvailable } = useProgress(); + const [count, setCount] = useState(5); + const [difficulty, setDifficulty] = useState("all"); + const [questions, setQuestions] = useState([]); + const [responses, setResponses] = useState>({}); + const [current, setCurrent] = useState(0); + const [startedAt, setStartedAt] = useState(0); + const [completed, setCompleted] = useState(null); + const [ownerScope, setOwnerScope] = useState(null); + const activeScope = user?.id ?? "guest"; + const finished = useRef(false); + const questionHeading = useRef(null); + const resultHeading = useRef(null); + + // DOM event timestamps and performance.now share a monotonic clock for duration. + const start = (timestamp: number) => { + finished.current = false; + setOwnerScope(activeScope); + setQuestions( + selectPracticeQuestions({ + topicId, + count, + difficulty: difficulty === "all" ? undefined : difficulty, + }), + ); + setResponses({}); + setCurrent(0); + setCompleted(null); + setStartedAt(timestamp); + requestAnimationFrame(() => questionHeading.current?.focus()); + }; + const move = (index: number) => { + setCurrent(index); + requestAnimationFrame(() => questionHeading.current?.focus()); + }; + const finish = () => { + if (finished.current || ownerScope !== activeScope) return; + finished.current = true; + const attempt: AttemptRecord = { + id: crypto.randomUUID(), + completedAt: new Date().toISOString(), + topicId, + questionIds: questions.map((question) => question.id), + responses: questions.flatMap((question) => + responses[question.id] + ? [{ questionId: question.id, optionId: responses[question.id] }] + : [], + ), + durationSeconds: Math.min( + 7200, + Math.max(0, Math.round((performance.now() - startedAt) / 1000)), + ), + }; + saveAttempt(attempt); + setCompleted(attempt); + requestAnimationFrame(() => resultHeading.current?.focus()); + }; + + if (ownerScope && ownerScope !== activeScope) + return ( +
+

Your learning account changed

+

+ Start a new session to save answers to the current workspace. +

+ +
+ ); + + if (completed) { + const summary = gradeSession(questions, completed.responses); + return ( + <> +

+ Practice completed +

+

+ Session complete +

+

+ Take a moment to review the reasoning. That is where this session pays + off. +

+
+
+
+

+ {topic.title} +

+

+ {summary.correct} + + {" "} + / {summary.totalQuestions} correct + +

+
+
+

+ + {summary.correct} + + Correct +

+

+ + {summary.incorrect} + + Incorrect +

+

+ + {summary.unanswered} + + Skipped +

+
+
+

+ {!storageAvailable + ? "This session is available for review here, but browser storage is unavailable." + : user + ? "Saved on this device. Your account syncs in the background." + : "Saved in this browser. You can revisit your progress from My learning."} +

+
+ + Review progress{" "} +
+
+
+

Answer review

+
+ {questions.map((question, index) => { + const answer = summary.answers.find( + (item) => item.questionId === question.id, + )!; + const selected = question.options.find( + (option) => option.id === answer.selectedOptionId, + ); + const correct = question.options.find( + (option) => option.id === answer.correctOptionId, + )!; + return ( +
+

+ {index + 1}.{" "} + {answer.isCorrect + ? "Correct" + : answer.isAnswered + ? "Review this answer" + : "Not answered"} +

+

+ {question.prompt} +

+
+
+
Your answer:
+
{selected?.text ?? "Skipped"}
+
+
+
Correct answer:
+
{correct.text}
+
+
+

+ {question.explanation} +

+
+ ); + })} +
+
+ + ); + } + + if (!questions.length) + return ( + <> + +