Initial commit

This commit is contained in:
MarcUs7i 2025-04-18 19:13:47 +02:00
commit ea82926500
58 changed files with 9323 additions and 0 deletions

41
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Build Docker Image
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'Server/**'
- '.github/workflows/build.yml'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./Server
file: ./Server/Dockerfile
push: true
platforms: linux/amd64, linux/arm64/v8
tags: ghcr.io/marcus7i/quizconnect:latest

160
.gitignore vendored Normal file
View file

@ -0,0 +1,160 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sw?
.env
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
*.azurePubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
packages/
## TODO: If the tool you use requires repositories.config, also uncomment the next line
!packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
![Ss]tyle[Cc]op.targets
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
_NCrunch*
.idea/

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# QuizConnect
A web application for creating and taking quizzes.
## Project Structure
- **Server**: .NET 9.0 backend with MongoDB integration
- RESTful API endpoints for user and admin functionality
- Question management services
- Authentication using tokens
- **Frontend**: React with TypeScript, Vite, and Tailwind CSS
- User dashboard for taking quizzes
- Admin dashboard for managing questions
## Getting Started
### Prerequisites
- Node.js
- .NET 9.0 SDK
- Docker and Docker Compose (for containerized deployment)
- MongoDB instance
### Development Setup
1. Clone the repository
2. Set up the backend:
```
cd Server
dotnet restore
dotnet run
```
3. Set up the frontend:
```
npm install
npm run dev
```
### Deployment
Use Docker Compose for easy deployment:
```
cd Server
docker compose up -d
```
## API Documentation
See `Server/Server/apiDoc.md` for detailed API documentation.

25
Server/.dockerignore Normal file
View file

@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

22
Server/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 9500
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Server/Server.csproj", "Server/"]
RUN dotnet restore "Server/Server.csproj"
COPY . .
WORKDIR "/src/Server"
RUN dotnet build "Server.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Server.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Server.dll"]

21
Server/Server.sln Normal file
View file

@ -0,0 +1,21 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{89ED2F06-A378-47E5-8DF8-A44335C01BBA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B5BEF36F-A30C-4690-BE85-5B97D83ED7F2}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89ED2F06-A378-47E5-8DF8-A44335C01BBA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,3 @@
MONGODB_URI=mongodb://user:password@localhost:27017/database
ADMIN_LOGIN_ID=1B3z
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...

View file

@ -0,0 +1,479 @@
using Microsoft.AspNetCore.Mvc;
using WritingServer.Models;
using WritingServer.Services;
using WritingServer.Utils;
namespace WritingServer.Controllers;
[ApiController]
[Route("api/admin")]
public class AdminController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IUserManagementService _userManagementService;
private readonly IAdminTokenService _tokenService;
private readonly IQuestionManagementService _questionManagementService;
public AdminController(IConfiguration configuration, IUserManagementService userManagementService,
IAdminTokenService tokenService, IQuestionManagementService questionManagementService)
{
_configuration = configuration;
_userManagementService = userManagementService;
_tokenService = tokenService;
_questionManagementService = questionManagementService;
}
[HttpGet("test-exception")]
public IActionResult TestException()
{
throw new Exception("This is a test exception");
}
[HttpPost("login")]
public ActionResult<AdminLoginResponse> Login([FromBody] AdminLoginRequest request)
{
var adminLoginId = _configuration["ADMIN_LOGIN_ID"];
if (string.IsNullOrEmpty(adminLoginId) || request.LoginId != adminLoginId)
{
return Ok(new AdminLoginResponse
{
Success = false,
ErrorMessage = "Invalid login credentials"
});
}
var token = TokenGenerator.GenerateToken(32);
_tokenService.StoreToken(token);
return Ok(new AdminLoginResponse
{
Success = true,
AccessToken = token
});
}
private string? GetBearerToken()
{
if (!HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return null;
}
string auth = authHeader.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return auth.Substring(7).Trim();
}
private bool ValidateAdminToken(string? token)
{
if (string.IsNullOrEmpty(token))
{
return false;
}
return _tokenService.ValidateToken(token);
}
#region User Management
[HttpGet("users")]
public async Task<ActionResult<AdminUserListResponse>> GetUsers()
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserListResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var users = await _userManagementService.GetAllUsersAsync();
return Ok(new AdminUserListResponse { Success = true, Users = users });
}
[HttpPost("users")]
public async Task<ActionResult<AdminUserResponse>> AddUser([FromBody] AdminAddUserRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.UserName))
{
return BadRequest(new AdminUserResponse
{
Success = false,
ErrorMessage = "Username cannot be empty"
});
}
await _userManagementService.AddUserAsync(request.UserName);
return Ok(new AdminUserResponse { Success = true });
}
[HttpPut("users")]
public async Task<ActionResult<AdminUserResponse>> UpdateUser([FromBody] AdminEditUserRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.NewUserName))
{
return BadRequest(new AdminUserResponse
{
Success = false,
ErrorMessage = "Username cannot be empty"
});
}
var success = await _userManagementService.UpdateUserNameAsync(request.UserName, request.NewUserName);
return Ok(new AdminUserResponse
{
Success = success,
ErrorMessage = success ? null : "Failed to update user - username may already exist"
});
}
[HttpPut("users/reset")]
public async Task<ActionResult<AdminUserResponse>> ResetUser([FromBody] AdminUserResetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var user = await _userManagementService.GetUserByNameAsync(request.UserName);
if (user == null)
{
return NotFound(new AdminUserResponse
{
Success = false,
ErrorMessage = "User not found"
});
}
var success = await _userManagementService.ResetUserAsync(request.UserName);
return Ok(new AdminUserResponse { Success = success });
}
[HttpDelete("users")]
public async Task<ActionResult<AdminUserResponse>> DeleteUser([FromBody] AdminDeleteUserRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminUserResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _userManagementService.DeleteUserAsync(request.UserName);
return Ok(new AdminUserResponse
{
Success = success,
ErrorMessage = success ? null : "User not found"
});
}
#endregion
#region Question Set Management
[HttpGet("questionsets")]
public async Task<ActionResult<AdminListQuestionSetsResponse>> GetQuestionSets()
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminListQuestionSetsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questionSets = await _questionManagementService.GetAllQuestionSetsAsync();
return Ok(new AdminListQuestionSetsResponse { Success = true, QuestionSets = questionSets });
}
[HttpPost("questionsets")]
public async Task<ActionResult<AdminQuestionSetResponse>> CreateQuestionSet([FromBody] AdminCreateQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.QuestionSetName))
{
return BadRequest(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set name cannot be empty"
});
}
var questionSet = await _questionManagementService.CreateQuestionSetAsync(request.QuestionSetName);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpPut("questionsets/order")]
public async Task<ActionResult<AdminQuestionSetResponse>> UpdateQuestionSetOrder([FromBody] AdminUpdateOrderQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.UpdateQuestionSetOrderAsync(request.QuestionSetId, request.QuestionSetOrder);
if (!success)
{
return NotFound(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set not found"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(request.QuestionSetId);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpPut("questionsets/lock")]
public async Task<ActionResult<AdminQuestionSetResponse>> UpdateQuestionSetLock([FromBody] AdminUpdateLockQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.UpdateQuestionSetLockStatusAsync(request.QuestionSetId, request.Locked);
if (!success)
{
return NotFound(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set not found"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(request.QuestionSetId);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpPut("questionsets/name")]
public async Task<ActionResult<AdminQuestionSetResponse>> UpdateQuestionSetName([FromBody] AdminUpdateNameQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.NewQuestionSetName))
{
return BadRequest(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set name cannot be empty"
});
}
var success = await _questionManagementService.UpdateQuestionSetNameAsync(request.QuestionSetId, request.NewQuestionSetName);
if (!success)
{
return NotFound(new AdminQuestionSetResponse
{
Success = false,
ErrorMessage = "Question set not found"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(request.QuestionSetId);
return Ok(new AdminQuestionSetResponse { Success = true, QuestionSet = questionSet });
}
[HttpDelete("questionsets")]
public async Task<ActionResult<AdminDeleteQuestionSetResponse>> DeleteQuestionSet([FromBody] AdminDeleteQuestionSetRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminDeleteQuestionSetResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.DeleteQuestionSetAsync(request.QuestionSetId);
return Ok(new AdminDeleteQuestionSetResponse { Success = success });
}
#endregion
#region Question Management
[HttpGet("questions")]
public async Task<ActionResult<AdminListQuestionsResponse>> GetQuestions([FromQuery] string questionSetId)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminListQuestionsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questions = await _questionManagementService.GetQuestionsInSetAsync(questionSetId);
return Ok(new AdminListQuestionsResponse { Success = true, Questions = questions });
}
[HttpPost("questions")]
public async Task<ActionResult<AdminQuestionResponse>> CreateQuestion([FromBody] AdminCreateQuestionRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.QuestionText))
{
return BadRequest(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Question text cannot be empty"
});
}
var question = await _questionManagementService.CreateQuestionAsync(
request.QuestionSetId,
request.QuestionText,
request.ExpectedResultText,
request.QuestionOrder,
request.MinWordLength,
request.MaxWordLength);
return Ok(new AdminQuestionResponse { Success = true, QuestionModels = question });
}
[HttpPut("questions")]
public async Task<ActionResult<AdminQuestionResponse>> UpdateQuestion([FromBody] AdminUpdateQuestionRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.QuestionText))
{
return BadRequest(new AdminQuestionResponse
{
Success = false,
ErrorMessage = "Question text cannot be empty"
});
}
var question = await _questionManagementService.UpdateQuestionAsync(
request.QuestionId,
request.QuestionText,
request.ExpectedResultText,
request.QuestionOrder,
request.MinWordLength,
request.MaxWordLength);
return Ok(new AdminQuestionResponse { Success = true, QuestionModels = question });
}
[HttpDelete("questions")]
public async Task<ActionResult<AdminDeleteQuestionResponse>> DeleteQuestion([FromBody] AdminDeleteQuestionRequest request)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminDeleteQuestionResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var success = await _questionManagementService.DeleteQuestionAsync(request.QuestionId);
return Ok(new AdminDeleteQuestionResponse { Success = success });
}
#endregion
#region Response Management
[HttpGet("responses")]
public async Task<ActionResult<AdminGetResponsesResponse>> GetResponses([FromQuery] string questionId)
{
string? token = GetBearerToken();
if (!ValidateAdminToken(token))
{
return Unauthorized(new AdminGetResponsesResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var responses = await _questionManagementService.GetResponsesForQuestionAsync(questionId);
return Ok(new AdminGetResponsesResponse { Success = true, Responses = responses });
}
#endregion
}

View file

@ -0,0 +1,283 @@
using Microsoft.AspNetCore.Mvc;
using WritingServer.Models;
using WritingServer.Services;
namespace WritingServer.Controllers;
[ApiController]
[Route("api/user")]
public class UserController : ControllerBase
{
private readonly IUserManagementService _userManagementService;
private readonly IQuestionManagementService _questionManagementService;
public UserController(IUserManagementService userManagementService, IQuestionManagementService questionManagementService)
{
_userManagementService = userManagementService;
_questionManagementService = questionManagementService;
}
[HttpPost("login")]
public async Task<ActionResult<UserLoginResponse>> Login([FromBody] UserLoginRequest request)
{
var user = await _userManagementService.GetUserByPinAsync(request.Pin);
if (user == null)
{
return Ok(new UserLoginResponse
{
Success = false,
ErrorMessage = "Invalid PIN"
});
}
var accessToken = _userManagementService.GenerateAccessToken(user.Username);
if (user.ResetState)
{
_userManagementService.ResetLoginState(user.Username);
}
return Ok(new UserLoginResponse
{
Success = true,
AccessToken = accessToken
});
}
[HttpGet("username")]
public async Task<ActionResult<UserGetUsernameResponse>> GetUsername()
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetUsernameResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetUsernameResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
return Ok(new UserGetUsernameResponse
{
Success = true,
Username = user.Username
});
}
[HttpGet("state")]
public async Task<ActionResult<UserGetStateResponse>> GetState()
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
return Ok(new UserGetStateResponse
{
Success = true,
ResetState = user.ResetState
});
}
[HttpGet("questionsets")]
public async Task<ActionResult<UserGetQuestionSetsResponse>> GetQuestionSets()
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetQuestionSetsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questionSets = await _questionManagementService.GetAllQuestionSetsAsync();
// Only return unlocked question sets to users
questionSets.QuestionSets = questionSets.QuestionSets.Where(qs => !qs.Locked).ToList();
return Ok(new UserGetQuestionSetsResponse
{
Success = true,
QuestionSets = questionSets
});
}
[HttpGet("questions")]
public async Task<ActionResult<UserGetQuestionsResponse>> GetQuestions([FromQuery] string questionSetId)
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetQuestionsResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var questionSet = await _questionManagementService.GetQuestionSetByIdAsync(questionSetId);
if (questionSet.Locked)
{
return BadRequest(new UserGetQuestionsResponse
{
Success = false,
ErrorMessage = "This question set is locked"
});
}
var questions = await _questionManagementService.GetQuestionsInSetAsync(questionSetId);
return Ok(new UserGetQuestionsResponse
{
Success = true,
Questions = questions
});
}
[HttpPost("responses")]
public async Task<ActionResult<UserSubmitResponseResponse>> SubmitResponse([FromBody] UserSubmitResponseRequest request)
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserSubmitResponseResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
if (string.IsNullOrWhiteSpace(request.ResponseText))
{
return BadRequest(new UserSubmitResponseResponse
{
Success = false,
ErrorMessage = "Response text cannot be empty"
});
}
var question = await _questionManagementService.GetQuestionByIdAsync(request.QuestionId);
var wordCount = request.ResponseText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (wordCount < question.MinWordLength || wordCount > question.MaxWordLength)
{
return BadRequest(new UserSubmitResponseResponse
{
Success = false,
ErrorMessage = $"Response must be between {question.MinWordLength} and {question.MaxWordLength} words"
});
}
await _questionManagementService.SubmitResponseAsync(request.QuestionId, user.Username, request.ResponseText);
return Ok(new UserSubmitResponseResponse { Success = true });
}
[HttpGet("responses")]
public async Task<ActionResult<UserGetResponsesResponse>> GetResponses([FromQuery] string questionId)
{
string? token = GetBearerToken();
if (string.IsNullOrEmpty(token))
{
return Unauthorized(new UserGetStateResponse
{
Success = false,
ErrorMessage = "Missing or invalid authorization token"
});
}
var user = await _userManagementService.GetUserByTokenAsync(token);
if (user == null)
{
return Unauthorized(new UserGetResponsesResponse
{
Success = false,
ErrorMessage = "Invalid access token"
});
}
var responses = await _questionManagementService.GetResponsesForUserAsync(user.Username, questionId);
return Ok(new UserGetResponsesResponse
{
Success = true,
Responses = responses
});
}
private string? GetBearerToken()
{
if (!HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return null;
}
string auth = authHeader.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return auth.Substring(7).Trim();
}
}

View file

@ -0,0 +1,191 @@
namespace WritingServer.Models;
#region Admin Login
public class AdminLoginRequest
{
public string LoginId { get; set; } = string.Empty;
}
public class AdminLoginResponse
{
public bool Success { get; set; }
public string AccessToken { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
#endregion
#region User Administration
public class AdminAddUserRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
public class AdminEditUserRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string NewUserName { get; set; } = string.Empty;
}
public class AdminDeleteUserRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
public class AdminUserResetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
public class AdminUserResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class AdminUserListRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class AdminUserListResponse
{
public bool Success { get; set; }
public UserModelList Users { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion
#region Question Set Management
public class AdminCreateQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetName { get; set; } = string.Empty;
}
public class AdminUpdateOrderQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public int QuestionSetOrder { get; set; }
}
public class AdminUpdateLockQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public bool Locked { get; set; }
}
public class AdminUpdateNameQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public string NewQuestionSetName { get; set; } = string.Empty;
}
public class AdminDeleteQuestionSetRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
}
public class AdminQuestionSetResponse
{
public bool Success { get; set; }
public QuestionSet QuestionSet { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class AdminDeleteQuestionSetResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class AdminListQuestionSetsRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class AdminListQuestionSetsResponse
{
public bool Success { get; set; }
public QuestionSetList QuestionSets { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion
#region Question Management
public class AdminCreateQuestionRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string ExpectedResultText { get; set; } = string.Empty;
public int QuestionOrder { get; set; }
public int MinWordLength { get; set; }
public int MaxWordLength { get; set; }
}
public class AdminUpdateQuestionRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string ExpectedResultText { get; set; } = string.Empty;
public int QuestionOrder { get; set; }
public int MinWordLength { get; set; }
public int MaxWordLength { get; set; }
}
public class AdminDeleteQuestionRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
}
public class AdminQuestionResponse
{
public bool Success { get; set; }
public QuestionModels QuestionModels { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class AdminDeleteQuestionResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class AdminListQuestionsRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
}
public class AdminListQuestionsResponse
{
public bool Success { get; set; }
public QuestionModelList Questions { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion
#region Reponses
public class AdminGetResponsesRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
}
public class AdminGetResponsesResponse
{
public bool Success { get; set; }
public ResponseList Responses { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion

View file

@ -0,0 +1,54 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace WritingServer.Models;
public class QuestionSet
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string QuestionSetId { get; set; } = string.Empty;
public string QuestionSetName { get; set; } = string.Empty;
public int QuestionSetOrder { get; set; } = 0;
public bool Locked { get; set; } = true;
public QuestionModels Questions { get; set; } = new();
}
public class QuestionSetList
{
public List<QuestionSet> QuestionSets { get; set; } = [];
}
public class QuestionModels
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string QuestionId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string ExpectedResultText { get; set; } = string.Empty;
public int QuestionOrder { get; set; } = 0;
public int MinWordLength { get; set; }
public int MaxWordLength { get; set; }
public ResponseList ResponseList { get; set; } = new();
}
public class QuestionModelList
{
public List<QuestionModels> Questions { get; set; } = [];
}
public class ResponseModels
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string ResponseId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
public string ResponseText { get; set; } = string.Empty;
public DateTime ResponseTime { get; set; }
}
public class ResponseList
{
public List<ResponseModels> Response { get; set; } = [];
}

View file

@ -0,0 +1,94 @@
namespace WritingServer.Models;
#region User Login
public class UserLoginRequest
{
public string Pin { get; set; } = string.Empty;
}
public class UserLoginResponse
{
public bool Success { get; set; }
public string AccessToken { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
#endregion
#region User States
public class UserGetUsernameRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class UserGetUsernameResponse
{
public bool Success { get; set; }
public string Username { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
public class UserGetResetState
{
public string AccessToken { get; set; } = string.Empty;
}
public class UserGetStateResponse
{
public bool Success { get; set; }
public bool ResetState { get; set; }
public string? ErrorMessage { get; set; }
}
#endregion
#region Questions
public class UserGetQuestionSetsRequest
{
public string AccessToken { get; set; } = string.Empty;
}
public class UserGetQuestionSetsResponse
{
public bool Success { get; set; }
public QuestionSetList QuestionSets { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class UserGetQuestionsRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionSetId { get; set; } = string.Empty;
}
public class UserGetQuestionsResponse
{
public bool Success { get; set; }
public QuestionModelList Questions { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class UserSubmitResponseRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
public string ResponseText { get; set; } = string.Empty;
}
public class UserSubmitResponseResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
}
public class UserGetResponsesRequest
{
public string AccessToken { get; set; } = string.Empty;
public string QuestionId { get; set; } = string.Empty;
}
public class UserGetResponsesResponse
{
public bool Success { get; set; }
public ResponseList Responses { get; set; } = new();
public string? ErrorMessage { get; set; }
}
#endregion

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace WritingServer.Models;
public class UserModel
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[JsonIgnore]
public string Id { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Pin { get; set; } = string.Empty;
public bool ResetState { get; set; } = false;
}
public class UserModelList
{
public List<UserModel> Users { get; set; } = [];
}

87
Server/Server/Program.cs Normal file
View file

@ -0,0 +1,87 @@
using System.Diagnostics;
using MongoDB.Driver;
using WritingServer.Services;
using WritingServer.Utils;
var startTime = DateTime.UtcNow;
var builder = WebApplication.CreateBuilder(args);
// Load .env file
DotNetEnv.Env.Load();
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddControllersWithViews();
builder.Services.AddHealthChecks();
// Add CORS configuration
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowClientApp", policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin();
});
});
// Add MongoDB client
builder.Services.AddSingleton<IMongoClient>(sp =>
{
var connectionString = Environment.GetEnvironmentVariable("MONGODB_URI");
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("MongoDB connection string not found in environment variables");
}
return new MongoClient(connectionString);
});
// Add MongoDB database
builder.Services.AddSingleton(sp =>
{
var client = sp.GetRequiredService<IMongoClient>();
var url = new MongoUrl(Environment.GetEnvironmentVariable("MONGODB_URI"));
return client.GetDatabase(url.DatabaseName);
});
// Add our services
builder.Services.AddSingleton<IDiscordNotificationService, DiscordNotificationService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddSingleton<IQuestionManagementService, QuestionManagementService>();
builder.Services.AddSingleton<IAdminTokenService, AdminTokenService>();
builder.Services.AddSingleton<IUserTokenService, UserTokenService>();
// Configuration
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
builder.Configuration.AddEnvironmentVariables();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapHealthChecks("/health");
// Use CORS before routing
app.UseCors("AllowClientApp");
var startupTime = DateTime.UtcNow - startTime;
var process = Process.GetCurrentProcess();
var memoryUsageMb = process.WorkingSet64 / (1024 * 1024);
var discordService = app.Services.GetRequiredService<IDiscordNotificationService>();
_ = discordService.SendStartupNotification(startupTime, memoryUsageMb);
app.UseGlobalExceptionHandler();
app.MapControllers();
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://*:9500",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7232;http://localhost:9500",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>WritingServer</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
<PackageReference Include="MongoDB.Driver" Version="3.3.0" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

224
Server/Server/Server.http Normal file
View file

@ -0,0 +1,224 @@
@baseUrl = http://localhost:9500/api
@questionId = {{createQuestion.response.body.questionModels.id}}
### Exception testing
# @name testException
GET {{baseUrl}}/admin/test-exception
### Admin Login
# @name loginAdmin
POST {{baseUrl}}/admin/login
Content-Type: application/json
{
"loginId": "{{loginAdmin}}"
}
> {%
const accessToken = response.body.accessToken;
client.global.set("adminToken", accessToken);
%}
### Admin - User Management ###
### List Users
GET {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
### Add User
# @name addUser
POST {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "TestUser123"
}
### Edit User
PUT {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "TestUser123",
"newUserName": "UpdatedUser123"
}
### Reset User
PUT {{baseUrl}}/admin/users/reset
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "UpdatedUser123"
}
### Delete User
DELETE {{baseUrl}}/admin/users
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"userName": "UpdatedUser123"
}
### Admin - Question Set Management ###
### List Question Sets
GET {{baseUrl}}/admin/questionsets
Authorization: Bearer {{adminToken}}
### Create Question Set
# @name createQuestionSet
POST {{baseUrl}}/admin/questionsets
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetName": "Test Question Set"
}
> {%
const questionSetId = response.body.questionSet.questionSetId;
client.global.set("questionSetId", questionSetId);
%}
### Update Question Set Name
PUT {{baseUrl}}/admin/questionsets/name
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"newQuestionSetName": "Updated Question Set"
}
### Update Question Set Order
PUT {{baseUrl}}/admin/questionsets/order
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"questionSetOrder": 2
}
### Lock/Unlock Question Set
PUT {{baseUrl}}/admin/questionsets/lock
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"locked": true
}
### Delete Question Set
DELETE {{baseUrl}}/admin/questionsets
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}"
}
### Admin - Question Management ###
### List Questions in a Set
GET {{baseUrl}}/admin/questions?questionSetId={{questionSetId}}
Authorization: Bearer {{adminToken}}
### Create Question
# @name createQuestion
POST {{baseUrl}}/admin/questions
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionSetId": "{{questionSetId}}",
"questionText": "What is your favorite color?",
"expectedResultText": "A color name and explanation",
"questionOrder": 1,
"minWordLength": 10,
"maxWordLength": 100
}
### Update Question
PUT {{baseUrl}}/admin/questions
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionId": "{{questionId}}",
"questionText": "What is your favorite programming language?",
"expectedResultText": "A programming language and why",
"questionOrder": 1,
"minWordLength": 20,
"maxWordLength": 200
}
### Delete Question
DELETE {{baseUrl}}/admin/questions
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"questionId": "{{questionId}}"
}
### Admin - Responses ###
### Get Responses for a Question
GET {{baseUrl}}/admin/responses?questionId={{questionId}}
Authorization: Bearer {{adminToken}}
### User APIs ###
### User Login
# @name loginUser
POST {{baseUrl}}/user/login
Content-Type: application/json
{
"pin": "2D21"
}
> {%
const userToken = response.body.accessToken;
client.global.set("userToken", userToken);
%}
### Get Username
GET {{baseUrl}}/user/username
Authorization: Bearer {{userToken}}
Content-Type: application/json
### Get Reset State
GET {{baseUrl}}/user/state
Authorization: Bearer {{userToken}}
Content-Type: application/json
### User - Questions ###
### Get Question Sets
GET {{baseUrl}}/user/questionsets
Authorization: Bearer {{userToken}}
Content-Type: application/json
### Get Questions from Set
# @name getQuestions
GET {{baseUrl}}/user/questions?questionSetId={{questionSetId}}
Authorization: Bearer {{userToken}}
### Submit Response
POST {{baseUrl}}/user/responses
Authorization: Bearer {{userToken}}
Content-Type: application/json
{
"questionId": "{{questionId}}",
"responseText": "This is my response to the question. Probably, what do you say?"
}
### Get User Responses
GET {{baseUrl}}/user/responses?questionId={{questionId}}
Authorization: Bearer {{userToken}}

24
Server/Server/Server.sln Normal file
View file

@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server.csproj", "{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3EE6B2D-0B19-306C-2C68-AB272C4E49C6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A18AA024-5978-4B91-8279-6928359C3DFD}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,22 @@
namespace WritingServer.Services;
public interface IAdminTokenService
{
void StoreToken(string token);
bool ValidateToken(string token);
}
public class AdminTokenService : IAdminTokenService
{
private readonly Dictionary<string, bool> _adminTokens = new();
public void StoreToken(string token)
{
_adminTokens[token] = true;
}
public bool ValidateToken(string token)
{
return _adminTokens.ContainsKey(token) && _adminTokens[token];
}
}

View file

@ -0,0 +1,62 @@
using System.Text;
using System.Text.Json;
namespace WritingServer.Services;
public interface IDiscordNotificationService
{
Task SendStartupNotification(TimeSpan startupTime, long memoryUsageMb);
Task SendResponseNotification(string username, string questionSetName, string questionText, string responseText);
Task SendExceptionNotification(string path);
Task SendDiscordMessage(string content);
}
public class DiscordNotificationService : IDiscordNotificationService
{
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
public DiscordNotificationService()
{
_httpClient = new HttpClient();
_webhookUrl = Environment.GetEnvironmentVariable("DISCORD_WEBHOOK") ?? "";
}
public async Task SendStartupNotification(TimeSpan startupTime, long memoryUsageMb)
{
await SendDiscordMessage($"Backend warmed up!\nTook {startupTime.TotalSeconds:F2} seconds.\nMemory usage: {memoryUsageMb} MB");
}
public async Task SendResponseNotification(string username, string questionSetName, string questionText, string responseText)
{
var message = $"New Response:\n**User:** {username}\n**Question Set:** {questionSetName}\n**Question:** {questionText}\n**Response:** {responseText}";
await SendDiscordMessage(message);
}
public async Task SendExceptionNotification(string path)
{
var message = $"An unhandled exception occurred at path: {path}\nMore details can be in found in `stdout`";
await SendDiscordMessage(message);
}
public async Task SendDiscordMessage(string content)
{
if (string.IsNullOrEmpty(_webhookUrl))
{
return;
}
var payload = new { content };
var json = JsonSerializer.Serialize(payload);
var stringContent = new StringContent(json, Encoding.UTF8, "application/json");
try
{
await _httpClient.PostAsync(_webhookUrl, stringContent);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to send Discord notification: {ex.Message}");
}
}
}

View file

@ -0,0 +1,246 @@
using MongoDB.Driver;
using WritingServer.Models;
using WritingServer.Utils;
namespace WritingServer.Services;
public interface IQuestionManagementService
{
// Question Set operations
Task<QuestionSet> CreateQuestionSetAsync(string name);
Task<bool> UpdateQuestionSetOrderAsync(string questionSetId, int order);
Task<bool> UpdateQuestionSetLockStatusAsync(string questionSetId, bool locked);
Task<bool> UpdateQuestionSetNameAsync(string questionSetId, string newName);
Task<bool> DeleteQuestionSetAsync(string questionSetId);
Task<QuestionSetList> GetAllQuestionSetsAsync();
Task<QuestionSet> GetQuestionSetByIdAsync(string questionSetId);
// Question operations
Task<QuestionModels> CreateQuestionAsync(string questionSetId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength);
Task<QuestionModels> UpdateQuestionAsync(string questionId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength);
Task<bool> DeleteQuestionAsync(string questionId);
Task<QuestionModelList> GetQuestionsInSetAsync(string questionSetId);
Task<QuestionModels> GetQuestionByIdAsync(string questionId);
// Response operations
Task<bool> SubmitResponseAsync(string questionId, string userName, string responseText);
Task<ResponseList> GetResponsesForQuestionAsync(string questionId);
Task<ResponseList> GetResponsesForUserAsync(string userName, string questionId);
}
public class QuestionManagementService : IQuestionManagementService
{
private readonly IMongoCollection<QuestionSet> _questionSetsCollection;
private readonly IMongoCollection<QuestionModels> _questionsCollection;
private readonly IMongoCollection<ResponseModels> _responsesCollection;
private readonly IDiscordNotificationService _discordNotificationService;
public QuestionManagementService(IMongoDatabase database, IDiscordNotificationService discordNotificationService)
{
_questionSetsCollection = database.GetCollection<QuestionSet>("QuestionSets");
_questionsCollection = database.GetCollection<QuestionModels>("Questions");
_responsesCollection = database.GetCollection<ResponseModels>("Responses");
_discordNotificationService = discordNotificationService;
}
#region Question Set operations
public async Task<QuestionSet> CreateQuestionSetAsync(string name)
{
var questionSet = new QuestionSet
{
QuestionSetId = TokenGenerator.GenerateToken(16),
QuestionSetName = name,
QuestionSetOrder = await GetNextQuestionSetOrderAsync(),
Locked = true
};
await _questionSetsCollection.InsertOneAsync(questionSet);
return questionSet;
}
private async Task<int> GetNextQuestionSetOrderAsync()
{
var maxOrder = await _questionSetsCollection
.Find(_ => true)
.SortByDescending(qs => qs.QuestionSetOrder)
.Limit(1)
.Project(qs => qs.QuestionSetOrder)
.FirstOrDefaultAsync();
return maxOrder + 1;
}
public async Task<bool> UpdateQuestionSetOrderAsync(string questionSetId, int order)
{
var result = await _questionSetsCollection.UpdateOneAsync(
qs => qs.QuestionSetId == questionSetId,
Builders<QuestionSet>.Update.Set(qs => qs.QuestionSetOrder, order));
return result.ModifiedCount > 0;
}
public async Task<bool> UpdateQuestionSetLockStatusAsync(string questionSetId, bool locked)
{
var result = await _questionSetsCollection.UpdateOneAsync(
qs => qs.QuestionSetId == questionSetId,
Builders<QuestionSet>.Update.Set(qs => qs.Locked, locked));
return result.ModifiedCount > 0;
}
public async Task<bool> UpdateQuestionSetNameAsync(string questionSetId, string newName)
{
var result = await _questionSetsCollection.UpdateOneAsync(
qs => qs.QuestionSetId == questionSetId,
Builders<QuestionSet>.Update.Set(qs => qs.QuestionSetName, newName));
return result.ModifiedCount > 0;
}
public async Task<bool> DeleteQuestionSetAsync(string questionSetId)
{
// First, find all questions in this set
var questions = await _questionsCollection
.Find(q => q.QuestionId.StartsWith(questionSetId + "_"))
.ToListAsync();
// Delete all responses for questions in this set
foreach (var question in questions)
{
await _responsesCollection.DeleteManyAsync(r => r.QuestionId == question.QuestionId);
}
// Delete all questions in this set
await _questionsCollection.DeleteManyAsync(q => q.QuestionId.StartsWith(questionSetId + "_"));
// Delete the question set
var result = await _questionSetsCollection.DeleteOneAsync(qs => qs.QuestionSetId == questionSetId);
return result.DeletedCount > 0;
}
public async Task<QuestionSetList> GetAllQuestionSetsAsync()
{
var questionSets = await _questionSetsCollection
.Find(_ => true)
.SortBy(qs => qs.QuestionSetOrder)
.ToListAsync();
return new QuestionSetList { QuestionSets = questionSets };
}
public async Task<QuestionSet> GetQuestionSetByIdAsync(string questionSetId)
{
return await _questionSetsCollection
.Find(qs => qs.QuestionSetId == questionSetId)
.FirstOrDefaultAsync();
}
#endregion
#region Question operations
public async Task<QuestionModels> CreateQuestionAsync(string questionSetId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength)
{
var question = new QuestionModels
{
QuestionId = $"{questionSetId}_{TokenGenerator.GenerateToken(8)}",
QuestionText = questionText,
ExpectedResultText = expectedResultText,
QuestionOrder = questionOrder,
MinWordLength = minWordLength,
MaxWordLength = maxWordLength
};
await _questionsCollection.InsertOneAsync(question);
return question;
}
public async Task<QuestionModels> UpdateQuestionAsync(string questionId, string questionText, string expectedResultText,
int questionOrder, int minWordLength, int maxWordLength)
{
var update = Builders<QuestionModels>.Update
.Set(q => q.QuestionText, questionText)
.Set(q => q.ExpectedResultText, expectedResultText)
.Set(q => q.QuestionOrder, questionOrder)
.Set(q => q.MinWordLength, minWordLength)
.Set(q => q.MaxWordLength, maxWordLength);
await _questionsCollection.UpdateOneAsync(q => q.QuestionId == questionId, update);
return await GetQuestionByIdAsync(questionId);
}
public async Task<bool> DeleteQuestionAsync(string questionId)
{
await _responsesCollection.DeleteManyAsync(r => r.QuestionId == questionId);
var result = await _questionsCollection.DeleteOneAsync(q => q.QuestionId == questionId);
return result.DeletedCount > 0;
}
public async Task<QuestionModelList> GetQuestionsInSetAsync(string questionSetId)
{
var questions = await _questionsCollection
.Find(q => q.QuestionId.StartsWith(questionSetId + "_"))
.SortBy(q => q.QuestionOrder)
.ToListAsync();
return new QuestionModelList { Questions = questions };
}
public async Task<QuestionModels> GetQuestionByIdAsync(string questionId)
{
return await _questionsCollection
.Find(q => q.QuestionId == questionId)
.FirstOrDefaultAsync();
}
#endregion
#region Response operations
public async Task<bool> SubmitResponseAsync(string questionId, string userName, string responseText)
{
var response = new ResponseModels
{
ResponseId = TokenGenerator.GenerateToken(16),
UserName = userName,
QuestionId = questionId,
ResponseText = responseText,
ResponseTime = DateTime.UtcNow
};
await _responsesCollection.InsertOneAsync(response);
// Discord webhook
var question = await GetQuestionByIdAsync(questionId);
string questionSetId = questionId.Split('_')[0];
var questionSet = await GetQuestionSetByIdAsync(questionSetId);
await _discordNotificationService.SendResponseNotification(
userName,
questionSet?.QuestionSetName ?? "Unknown Set",
question?.QuestionText ?? "Unknown Question",
responseText);
return true;
}
public async Task<ResponseList> GetResponsesForQuestionAsync(string questionId)
{
var responses = await _responsesCollection
.Find(r => r.QuestionId == questionId)
.SortByDescending(r => r.ResponseTime)
.ToListAsync();
return new ResponseList { Response = responses };
}
public async Task<ResponseList> GetResponsesForUserAsync(string userName, string questionId)
{
var responses = await _responsesCollection
.Find(r => r.QuestionId == questionId && r.UserName == userName)
.SortByDescending(r => r.ResponseTime)
.ToListAsync();
return new ResponseList { Response = responses };
}
#endregion
}

View file

@ -0,0 +1,138 @@
using MongoDB.Driver;
using WritingServer.Models;
using WritingServer.Utils;
namespace WritingServer.Services;
public interface IUserManagementService
{
Task<UserModel> AddUserAsync(string userName);
Task<bool> DeleteUserAsync(string userName);
Task<bool> UpdateUserNameAsync(string currentUserName, string newUserName);
Task<bool> ResetUserAsync(string userName);
Task<UserModelList> GetAllUsersAsync();
Task<UserModel?> GetUserByPinAsync(string pin);
Task<UserModel?> GetUserByTokenAsync(string accessToken);
Task<UserModel?> GetUserByNameAsync(string userName);
string GenerateAccessToken(string username);
void ResetLoginState(string userName);
}
public class UserManagementService : IUserManagementService
{
private readonly IMongoDatabase _database;
private readonly IMongoCollection<UserModel> _usersCollection;
private readonly IMongoCollection<ResponseModels> _responsesCollection;
private readonly IUserTokenService _tokenService;
public UserManagementService(IMongoDatabase database, IUserTokenService tokenService)
{
_database = database;
_usersCollection = database.GetCollection<UserModel>("Users");
_responsesCollection = database.GetCollection<ResponseModels>("Responses");
_tokenService = tokenService;
}
public async Task<UserModel> AddUserAsync(string userName)
{
var existingUser = await _usersCollection.Find(u => u.Username == userName).FirstOrDefaultAsync();
if (existingUser != null)
{
return existingUser;
}
var pin = TokenGenerator.IdGenerator(4);
var user = new UserModel
{
Username = userName,
Pin = pin,
ResetState = false
};
await _usersCollection.InsertOneAsync(user);
return user;
}
public async Task<bool> DeleteUserAsync(string userName)
{
var deleteResponseResult = await _responsesCollection.DeleteManyAsync(r => r.UserName == userName);
var deleteUserResult = await _usersCollection.DeleteOneAsync(u => u.Username == userName);
_tokenService.RemoveTokensForUser(userName);
return deleteUserResult.DeletedCount > 0;
}
public async Task<bool> UpdateUserNameAsync(string currentUserName, string newUserName)
{
var existingUser = await _usersCollection.Find(u => u.Username == newUserName).FirstOrDefaultAsync();
if (existingUser != null)
{
return false;
}
// Update username
var updateResult = await _usersCollection.UpdateOneAsync(
u => u.Username == currentUserName,
Builders<UserModel>.Update.Set(u => u.Username, newUserName));
// Update references in responses
await _responsesCollection.UpdateManyAsync(
r => r.UserName == currentUserName,
Builders<ResponseModels>.Update.Set(r => r.UserName, newUserName));
_tokenService.UpdateUserInTokens(currentUserName, newUserName);
return updateResult.ModifiedCount > 0;
}
public async Task<bool> ResetUserAsync(string userName)
{
await _responsesCollection.DeleteManyAsync(r => r.UserName == userName);
// Set reset flag
var updateResult = await _usersCollection.UpdateOneAsync(
u => u.Username == userName,
Builders<UserModel>.Update.Set(u => u.ResetState, true));
return updateResult.ModifiedCount > 0;
}
public async Task<UserModelList> GetAllUsersAsync()
{
var users = await _usersCollection.Find(_ => true).ToListAsync();
return new UserModelList { Users = users };
}
public async Task<UserModel?> GetUserByPinAsync(string pin)
{
return await _usersCollection.Find(u => u.Pin == pin).FirstOrDefaultAsync();
}
public async Task<UserModel?> GetUserByNameAsync(string userName)
{
return await _usersCollection.Find(u => u.Username == userName).FirstOrDefaultAsync();
}
public async Task<UserModel?> GetUserByTokenAsync(string accessToken)
{
var username = _tokenService.GetUsernameFromToken(accessToken);
return username != null ? await GetUserByNameAsync(username) : null;
}
// Helper methods for token management
public string GenerateAccessToken(string userName)
{
var token = TokenGenerator.GenerateToken(32);
_tokenService.StoreToken(token, userName);
return token;
}
public void ResetLoginState(string userName)
{
_usersCollection.UpdateOne(
u => u.Username == userName,
Builders<UserModel>.Update.Set(u => u.ResetState, false));
}
}

View file

@ -0,0 +1,60 @@
namespace WritingServer.Services;
public interface IUserTokenService
{
void StoreToken(string token, string username);
string? GetUsernameFromToken(string token);
bool ValidateToken(string token);
void ClearToken(string token);
void RemoveTokensForUser(string username);
void UpdateUserInTokens(string oldUsername, string newUsername);
}
public class UserTokenService : IUserTokenService
{
private readonly Dictionary<string, string> _userTokens = new(); // token -> username
public void StoreToken(string token, string username)
{
_userTokens[token] = username;
}
public string? GetUsernameFromToken(string token)
{
return _userTokens.TryGetValue(token, out var username) ? username : null;
}
public bool ValidateToken(string token)
{
return _userTokens.ContainsKey(token);
}
public void ClearToken(string token)
{
_userTokens.Remove(token);
}
public void RemoveTokensForUser(string username)
{
var tokensToRemove = _userTokens
.Where(kvp => kvp.Value == username)
.Select(kvp => kvp.Key)
.ToList();
foreach (var token in tokensToRemove)
{
_userTokens.Remove(token);
}
}
public void UpdateUserInTokens(string oldUsername, string newUsername)
{
foreach (var key in _userTokens.Keys.ToList())
{
if (_userTokens[key] == oldUsername)
{
_userTokens[key] = newUsername;
}
}
}
}

View file

@ -0,0 +1,59 @@
using WritingServer.Services;
namespace WritingServer.Utils;
public class GlobalExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly IDiscordNotificationService _discordService;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
private readonly IWebHostEnvironment _environment;
public GlobalExceptionHandlerMiddleware(
RequestDelegate next,
IDiscordNotificationService discordService,
ILogger<GlobalExceptionHandlerMiddleware> logger,
IWebHostEnvironment environment)
{
_next = next;
_discordService = discordService;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred while processing request to {Path}", context.Request.Path);
await _discordService.SendExceptionNotification(context.Request.Path);
await HandleExceptionAsync(context);
}
}
private static async Task HandleExceptionAsync(HttpContext context)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var response = new
{
Success = false,
ErrorMessage = "An unexpected error occurred. Please try again later."
};
await context.Response.WriteAsJsonAsync(response);
}
}
public static class GlobalExceptionHandlerMiddlewareExtensions
{
public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder builder)
{
return builder.UseMiddleware<GlobalExceptionHandlerMiddleware>();
}
}

View file

@ -0,0 +1,32 @@
using System.Security.Cryptography;
namespace WritingServer.Utils;
public static class TokenGenerator
{
public static string IdGenerator(int length)
{
Random random = new Random();
string numbers = "0123456789ABCDEF";
string chars = string.Empty;
for (int i = 0; i < length; i++)
{
chars += numbers[random.Next(numbers.Length)];
}
return chars;
}
public static string GenerateToken(int length)
{
byte[] randomBytes = new byte[length / 2];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomBytes);
}
string hexString = Convert.ToHexString(randomBytes);
return hexString.Length > length ? hexString.Substring(0, length) : hexString;
}
}

594
Server/Server/apiDoc.md Normal file
View file

@ -0,0 +1,594 @@
# WritingServer API Documentation
This document outlines all available API endpoints in the WritingServer application.
## Table of Contents
- [Authentication](#authentication)
- [Admin API](#admin-api)
- [User Management](#admin-user-management)
- [Question Set Management](#admin-question-set-management)
- [Question Management](#admin-question-management)
- [Response Management](#admin-response-management)
- [User API](#user-api)
- [Profile Management](#user-profile-management)
- [Questions](#user-questions)
- [Responses](#user-responses)
## Authentication
### Admin Login
Authenticates an admin user and returns an access token.
**Endpoint:** `POST /api/admin/login`
**Request Body:**
```json
{
"loginId": "string"
}
```
**Response:**
```json
{
"success": true,
"accessToken": "string",
"errorMessage": null
}
```
### User Login
Authenticates a user with their PIN and returns an access token.
**Endpoint:** `POST /api/user/login`
**Request Body:**
```json
{
"pin": "string"
}
```
**Response:**
```json
{
"success": true,
"accessToken": "string",
"errorMessage": null
}
```
## Admin API
All admin endpoints require authentication using the `Authorization: Bearer {token}` header.
### Admin User Management
#### List Users
Returns a list of all users.
**Endpoint:** `GET /api/admin/users`
**Response:**
```json
{
"success": true,
"users": {
"users": [
{
"username": "string",
"pin": "string",
"resetState": false
}
]
},
"errorMessage": null
}
```
#### Add User
Creates a new user with the specified username.
**Endpoint:** `POST /api/admin/users`
**Request Body:**
```json
{
"userName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Edit User
Updates a user's username.
**Endpoint:** `PUT /api/admin/users`
**Request Body:**
```json
{
"userName": "string",
"newUserName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Reset User
Resets a user's responses and sets their reset state.
**Endpoint:** `PUT /api/admin/users/reset`
**Request Body:**
```json
{
"userName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Delete User
Deletes a user and all associated responses.
**Endpoint:** `DELETE /api/admin/users`
**Request Body:**
```json
{
"userName": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
### Admin Question Set Management
#### List Question Sets
Returns all question sets.
**Endpoint:** `GET /api/admin/questionsets`
**Response:**
```json
{
"success": true,
"questionSets": {
"questionSets": [
{
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
}
]
},
"errorMessage": null
}
```
#### Create Question Set
Creates a new question set.
**Endpoint:** `POST /api/admin/questionsets`
**Request Body:**
```json
{
"questionSetName": "string"
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
},
"errorMessage": null
}
```
#### Update Question Set Name
Updates a question set's name.
**Endpoint:** `PUT /api/admin/questionsets/name`
**Request Body:**
```json
{
"questionSetId": "string",
"newQuestionSetName": "string"
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
},
"errorMessage": null
}
```
#### Update Question Set Order
Updates a question set's display order.
**Endpoint:** `PUT /api/admin/questionsets/order`
**Request Body:**
```json
{
"questionSetId": "string",
"questionSetOrder": 0
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
},
"errorMessage": null
}
```
#### Lock/Unlock Question Set
Toggles a question set's locked status.
**Endpoint:** `PUT /api/admin/questionsets/lock`
**Request Body:**
```json
{
"questionSetId": "string",
"locked": true
}
```
**Response:**
```json
{
"success": true,
"questionSet": {
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": true
},
"errorMessage": null
}
```
#### Delete Question Set
Deletes a question set and all associated questions.
**Endpoint:** `DELETE /api/admin/questionsets`
**Request Body:**
```json
{
"questionSetId": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
### Admin Question Management
#### List Questions in a Set
Returns all questions in a specified question set.
**Endpoint:** `GET /api/admin/questions?questionSetId={questionSetId}`
**Response:**
```json
{
"success": true,
"questions": {
"questions": [
{
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
]
},
"errorMessage": null
}
```
#### Create Question
Creates a new question in a question set.
**Endpoint:** `POST /api/admin/questions`
**Request Body:**
```json
{
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
```
**Response:**
```json
{
"success": true,
"questionModels": {
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
},
"errorMessage": null
}
```
#### Update Question
Updates an existing question.
**Endpoint:** `PUT /api/admin/questions`
**Request Body:**
```json
{
"questionId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
```
**Response:**
```json
{
"success": true,
"questionModels": {
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
},
"errorMessage": null
}
```
#### Delete Question
Deletes a question and associated responses.
**Endpoint:** `DELETE /api/admin/questions`
**Request Body:**
```json
{
"questionId": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
### Admin Response Management
#### Get Responses for a Question
Returns all user responses for a specific question.
**Endpoint:** `GET /api/admin/responses?questionId={questionId}`
**Response:**
```json
{
"success": true,
"responses": {
"responses": [
{
"responseId": "string",
"questionId": "string",
"userName": "string",
"responseText": "string",
"timestamp": "string"
}
]
},
"errorMessage": null
}
```
## User API
All user endpoints require authentication using the `Authorization: Bearer {token}` header.
### User Profile Management
#### Get Username
Returns the current user's username.
**Endpoint:** `GET /api/user/username`
**Response:**
```json
{
"success": true,
"username": "string",
"errorMessage": null
}
```
#### Get Reset State
Returns whether the user's account has been reset.
**Endpoint:** `GET /api/user/state`
**Response:**
```json
{
"success": true,
"resetState": false,
"errorMessage": null
}
```
### User Questions
#### Get Question Sets
Returns all unlocked question sets.
**Endpoint:** `GET /api/user/questionsets`
**Response:**
```json
{
"success": true,
"questionSets": {
"questionSets": [
{
"questionSetId": "string",
"questionSetName": "string",
"questionSetOrder": 0,
"locked": false
}
]
},
"errorMessage": null
}
```
#### Get Questions from Set
Returns all questions in a specified question set.
**Endpoint:** `GET /api/user/questions?questionSetId={questionSetId}`
**Response:**
```json
{
"success": true,
"questions": {
"questions": [
{
"questionId": "string",
"questionSetId": "string",
"questionText": "string",
"expectedResultText": "string",
"questionOrder": 0,
"minWordLength": 0,
"maxWordLength": 0
}
]
},
"errorMessage": null
}
```
### User Responses
#### Submit Response
Submits a user's response to a question.
**Endpoint:** `POST /api/user/responses`
**Request Body:**
```json
{
"questionId": "string",
"responseText": "string"
}
```
**Response:**
```json
{
"success": true,
"errorMessage": null
}
```
#### Get User Responses
Returns all of the current user's responses to a specific question.
**Endpoint:** `GET /api/user/responses?questionId={questionId}`
**Response:**
```json
{
"success": true,
"responses": {
"responses": [
{
"responseId": "string",
"questionId": "string",
"userName": "string",
"responseText": "string",
"timestamp": "string"
}
]
},
"errorMessage": null
}
```

View file

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"DefaultAdminUserName": "admin",
"DefaultAdminPassword": "password",
"IdLength": 8,
"TokenLength": 1024
}

View file

@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"DefaultAdminUserName": "admin",
"DefaultAdminPassword": "password",
"IdLength": 8,
"TokenLength": 1024
}

View file

@ -0,0 +1,8 @@
{
"dev": {
"loginAdmin": "admin123",
"loginUser": "value",
"createQuestionSet": "value",
"createQuestion": "value"
}
}

View file

@ -0,0 +1,12 @@
services:
quizconnectserver:
build:
context: .
dockerfile: Dockerfile
environment:
- APP_UID=1000
ports:
- "9500:9500"
volumes:
- ./Server/.env:/app/.env
restart: unless-stopped

View file

@ -0,0 +1,11 @@
services:
quizconnectserver:
image: ghcr.io/marcus7i/quizconnect:latest
container_name: quizconnectserver
environment:
- APP_UID=1000
ports:
- "9500:9500"
volumes:
- ./Server/.env:/app/.env
restart: unless-stopped

28
eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QuizConnect</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4355
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "quiz-connect",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.24.1",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.1",
"zustand": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

30
src/App.tsx Normal file
View file

@ -0,0 +1,30 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { AdminDashboard } from './pages/AdminDashboard';
import { Quiz } from './pages/Quiz';
import { useAuthStore } from './store/auth';
const queryClient = new QueryClient();
function App() {
const { token, isAdmin } = useAuthStore();
return (
<QueryClientProvider client={queryClient}>
<Router>
<Routes>
<Route path="/login" element={!token ? <Login /> : <Navigate to={isAdmin ? "/admin" : "/dashboard"} />} />
<Route path="/dashboard" element={token && !isAdmin ? <Dashboard /> : <Navigate to={isAdmin ? "/admin" : "/login"} />} />
<Route path="/admin" element={token && isAdmin ? <AdminDashboard /> : <Navigate to="/login" />} />
<Route path="/quiz/:questionSetId" element={token && !isAdmin ? <Quiz /> : <Navigate to="/login" />} />
<Route path="*" element={<Navigate to={token ? (isAdmin ? "/admin" : "/dashboard") : "/login"} />} />
</Routes>
</Router>
</QueryClientProvider>
);
}
export default App;

36
src/components/Button.tsx Normal file
View file

@ -0,0 +1,36 @@
import React from 'react';
import clsx from 'clsx';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
className,
...props
}) => {
return (
<button
className={clsx(
'rounded-lg font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900',
{
'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 text-white': variant === 'primary',
'bg-gray-700 hover:bg-gray-600 focus:ring-gray-500 text-white': variant === 'secondary',
'bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white': variant === 'danger',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
)}
{...props}
>
{children}
</button>
);
};

View file

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import clsx from 'clsx';
interface CodeInputProps {
length?: number;
onComplete: (code: string) => void;
}
export const CodeInput: React.FC<CodeInputProps> = ({ length = 4, onComplete }) => {
const [code, setCode] = useState<string[]>(Array(length).fill(''));
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (index: number, value: string) => {
if (value.length > 1) return;
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
if (value && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
if (newCode.every(digit => digit !== '')) {
onComplete(newCode.join(''));
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
return (
<div className="flex gap-4">
{Array.from({ length }).map((_, index) => (
<input
key={index}
ref={el => inputRefs.current[index] = el}
type="text"
maxLength={1}
className={clsx(
"w-14 h-14 text-center text-2xl rounded-lg",
"bg-gray-800 border-2 border-gray-700",
"focus:border-blue-500 focus:outline-none",
"text-white placeholder-gray-500"
)}
value={code[index]}
onChange={e => handleChange(index, e.target.value)}
onKeyDown={e => handleKeyDown(index, e)}
/>
))}
</div>
);
};

76
src/components/Layout.tsx Normal file
View file

@ -0,0 +1,76 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { LogOut } from 'lucide-react';
import { Button } from './Button';
import { useAuthStore } from '../store/auth';
import { checkUserResetState } from '../lib/api';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate();
const { logout, isAdmin } = useAuthStore();
const pollingIntervalRef = useRef<number | null>(null);
const handleLogout = () => {
logout();
navigate('/login');
};
// Check if the user has been reset by an admin
useEffect(() => {
if (!isAdmin) {
const checkResetState = async () => {
try {
const resetState = await checkUserResetState();
if (resetState === true) {
console.log('User has been reset by admin. Logging out...');
logout();
navigate('/login', {
state: {
message: 'Your account has been reset by an administrator. Please log in again.'
}
});
}
} catch (error) {
console.error('Error checking reset state:', error);
}
};
checkResetState();
pollingIntervalRef.current = window.setInterval(checkResetState, 5000);
return () => {
if (pollingIntervalRef.current !== null) {
clearInterval(pollingIntervalRef.current);
}
};
}
}, [isAdmin, logout, navigate]);
return (
<div className="min-h-screen bg-gray-900">
<header className="bg-gray-800 border-b border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<h1 className="text-xl font-bold text-white">
QuizConnect {isAdmin && <span className="text-blue-500">(Admin)</span>}
</h1>
<Button
variant="secondary"
size="sm"
onClick={handleLogout}
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
};

View file

@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react';
import { Check, X } from 'lucide-react';
import { Button } from './Button';
import { Question } from '../types/api';
interface QuestionCardProps {
question: Question;
onSubmit: (response: string) => Promise<void>;
currentNumber: number;
totalQuestions: number;
isAnswered?: boolean;
previousResponse?: string;
}
export const QuestionCard: React.FC<QuestionCardProps> = ({
question,
onSubmit,
currentNumber,
totalQuestions,
isAnswered = false,
previousResponse = '',
}) => {
const [response, setResponse] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Set response field to the previous response whenever the question changes or when the previous response or isAnswered status changes
useEffect(() => {
if (isAnswered && previousResponse) {
setResponse(previousResponse);
} else if (!isAnswered) {
setResponse('');
}
}, [question.questionId, isAnswered, previousResponse]);
const handleSubmit = async () => {
if (!response.trim()) {
setError('Please enter your response');
return;
}
const wordCount = response.trim().split(/\s+/).length;
if (wordCount < question.minWordLength || wordCount > question.maxWordLength) {
setError(`Response must be between ${question.minWordLength} and ${question.maxWordLength} words`);
return;
}
setIsSubmitting(true);
setError(null);
try {
await onSubmit(response);
} catch (err) {
setError('Failed to submit response. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 shadow-xl">
<h2 className="text-xl font-semibold text-white mb-4">{question.questionText}</h2>
<div className="mb-6">
<textarea
value={response}
onChange={(e) => setResponse(e.target.value)}
disabled={isAnswered}
className="w-full h-32 bg-gray-700 border-2 border-gray-600 rounded-lg p-3 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={isAnswered ? 'Question already answered' : 'Type your response here...'}
/>
<div className="mt-2 flex items-center justify-between text-sm">
<span className="text-gray-400">
Word count: {response.trim().split(/\s+/).filter(Boolean).length}
</span>
<span className="text-gray-400">
Required: {question.minWordLength}-{question.maxWordLength} words
</span>
</div>
</div>
{error && (
<div className="mb-4 flex items-center gap-2 text-red-500">
<X className="w-4 h-4" />
<span>{error}</span>
</div>
)}
<Button
onClick={handleSubmit}
disabled={isSubmitting || isAnswered}
className="w-full flex items-center justify-center gap-2"
>
<Check className="w-4 h-4" />
{isAnswered ? 'Already Submitted' : 'Submit Response'}
</Button>
</div>
);
};

View file

@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { Button } from './Button';
import { X } from 'lucide-react';
interface QuestionFormProps {
onSubmit: (data: {
questionText: string;
expectedResultText: string;
minWordLength: number;
maxWordLength: number;
}) => void;
onCancel: () => void;
initialData?: {
questionText: string;
expectedResultText: string;
minWordLength: number;
maxWordLength: number;
};
}
export const QuestionForm: React.FC<QuestionFormProps> = ({
onSubmit,
onCancel,
initialData,
}) => {
const [formData, setFormData] = useState({
questionText: initialData?.questionText || '',
expectedResultText: initialData?.expectedResultText || '',
minWordLength: initialData?.minWordLength || 50,
maxWordLength: initialData?.maxWordLength || 500,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.questionText.trim()) {
newErrors.questionText = 'Question text is required';
}
if (!formData.expectedResultText.trim()) {
newErrors.expectedResultText = 'Expected result is required';
}
if (formData.minWordLength < 1) {
newErrors.minWordLength = 'Minimum word length must be at least 1';
}
if (formData.maxWordLength <= formData.minWordLength) {
newErrors.maxWordLength = 'Maximum word length must be greater than minimum';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
}
};
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black bg-opacity-50" onClick={onCancel} />
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-2xl relative z-10">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-white">
{initialData ? 'Edit Question' : 'Add New Question'}
</h2>
<Button variant="secondary" size="sm" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Question Text
</label>
<textarea
value={formData.questionText}
onChange={(e) => setFormData({ ...formData, questionText: e.target.value })}
className={`w-full bg-gray-700 border ${
errors.questionText ? 'border-red-500' : 'border-gray-600'
} rounded-lg p-3 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
rows={4}
placeholder="Enter the question text..."
/>
{errors.questionText && (
<p className="mt-1 text-sm text-red-500">{errors.questionText}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Expected Result
</label>
<textarea
value={formData.expectedResultText}
onChange={(e) => setFormData({ ...formData, expectedResultText: e.target.value })}
className={`w-full bg-gray-700 border ${
errors.expectedResultText ? 'border-red-500' : 'border-gray-600'
} rounded-lg p-3 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
rows={4}
placeholder="Enter the expected result..."
/>
{errors.expectedResultText && (
<p className="mt-1 text-sm text-red-500">{errors.expectedResultText}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Minimum Word Length
</label>
<input
type="number"
value={formData.minWordLength}
onChange={(e) => setFormData({ ...formData, minWordLength: parseInt(e.target.value) || 0 })}
className={`w-full bg-gray-700 border ${
errors.minWordLength ? 'border-red-500' : 'border-gray-600'
} rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
min="1"
/>
{errors.minWordLength && (
<p className="mt-1 text-sm text-red-500">{errors.minWordLength}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Maximum Word Length
</label>
<input
type="number"
value={formData.maxWordLength}
onChange={(e) => setFormData({ ...formData, maxWordLength: parseInt(e.target.value) || 0 })}
className={`w-full bg-gray-700 border ${
errors.maxWordLength ? 'border-red-500' : 'border-gray-600'
} rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
min={formData.minWordLength + 1}
/>
{errors.maxWordLength && (
<p className="mt-1 text-sm text-red-500">{errors.maxWordLength}</p>
)}
</div>
</div>
<div className="flex justify-end gap-4 mt-6">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">
{initialData ? 'Save Changes' : 'Add Question'}
</Button>
</div>
</form>
</div>
</div>
);
};

View file

@ -0,0 +1,32 @@
import React from 'react';
import { ChevronRight, Lock } from 'lucide-react';
import { QuestionSet } from '../types/api';
interface QuestionSetCardProps {
questionSet: QuestionSet;
onClick: () => void;
}
export const QuestionSetCard: React.FC<QuestionSetCardProps> = ({ questionSet, onClick }) => {
return (
<button
onClick={onClick}
disabled={questionSet.locked}
className="w-full bg-gray-800 rounded-lg p-6 text-left transition-colors hover:bg-gray-750 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white mb-1">
{questionSet.questionSetName}
</h3>
<p className="text-sm text-gray-400">Question Set #{questionSet.questionSetOrder}</p>
</div>
{questionSet.locked ? (
<Lock className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-blue-500" />
)}
</div>
</button>
);
};

15
src/index.css Normal file
View file

@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
body {
@apply bg-gray-900 text-white;
}
* {
@apply border-gray-700;
}

172
src/lib/api.ts Normal file
View file

@ -0,0 +1,172 @@
import axios from 'axios';
import { useAuthStore } from '../store/auth';
const api = axios.create({
baseURL: 'https://quizconnect.marcus7i.net/api',
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(error);
}
);
// Auth endpoints
export const login = async (pin: string) => {
const response = await api.post('/user/login', { pin });
return response.data;
};
export const adminLogin = async (loginId: string) => {
const response = await api.post('/admin/login', { loginId });
return response.data;
};
// Admin endpoints
export const getAdminQuestionSets = async () => {
const response = await api.get('/admin/questionsets');
return response.data.questionSets.questionSets;
};
export const getAdminUsers = async () => {
const response = await api.get('/admin/users');
return response.data.users.users;
};
export const getAdminQuestions = async (questionSetId: string) => {
const response = await api.get(`/admin/questions?questionSetId=${questionSetId}`);
return response.data.questions.questions;
};
export const getAdminResponses = async (questionId: string) => {
const response = await api.get(`/admin/responses?questionId=${questionId}`);
return response.data.responses.response || [];
};
export const createUser = async (userName: string) => {
const response = await api.post('/admin/users', { userName });
return response.data;
};
export const updateUser = async (userName: string, newUserName: string) => {
const response = await api.put('/admin/users', { userName, newUserName });
return response.data;
};
export const resetUser = async (userName: string) => {
const response = await api.put('/admin/users/reset', { userName });
return response.data;
};
export const deleteUser = async (userName: string) => {
const response = await api.delete('/admin/users', { data: { userName } });
return response.data;
};
export const createQuestionSet = async (questionSetName: string) => {
const response = await api.post('/admin/questionsets', { questionSetName });
return response.data;
};
export const updateQuestionSetName = async (questionSetId: string, newQuestionSetName: string) => {
const response = await api.put('/admin/questionsets/name', { questionSetId, newQuestionSetName });
return response.data;
};
export const updateQuestionSetOrder = async (questionSetId: string, questionSetOrder: number) => {
const response = await api.put('/admin/questionsets/order', { questionSetId, questionSetOrder });
return response.data;
};
export const toggleQuestionSetLock = async (questionSetId: string, locked: boolean) => {
const response = await api.put('/admin/questionsets/lock', { questionSetId, locked });
return response.data;
};
export const deleteQuestionSet = async (questionSetId: string) => {
const response = await api.delete('/admin/questionsets', { data: { questionSetId } });
return response.data;
};
export const createQuestion = async (
questionSetId: string,
questionText: string,
expectedResultText: string,
questionOrder: number,
minWordLength: number,
maxWordLength: number
) => {
const response = await api.post('/admin/questions', {
questionSetId,
questionText,
expectedResultText,
questionOrder,
minWordLength,
maxWordLength,
});
return response.data;
};
export const updateQuestion = async (
questionId: string,
questionText: string,
expectedResultText: string,
questionOrder: number,
minWordLength: number,
maxWordLength: number
) => {
const response = await api.put('/admin/questions', {
questionId,
questionText,
expectedResultText,
questionOrder,
minWordLength,
maxWordLength,
});
return response.data;
};
export const deleteQuestion = async (questionId: string) => {
const response = await api.delete('/admin/questions', { data: { questionId } });
return response.data;
};
// User endpoints
export const getQuestionSets = async () => {
const response = await api.get('/user/questionsets');
return response.data.questionSets.questionSets;
};
export const getQuestions = async (questionSetId: string) => {
const response = await api.get(`/user/questions?questionSetId=${questionSetId}`);
return response.data.questions.questions;
};
export const submitResponse = async (questionId: string, responseText: string) => {
const response = await api.post('/user/responses', { questionId, responseText });
return response.data;
};
export const getUserResponses = async (questionId: string) => {
const response = await api.get(`/user/responses?questionId=${questionId}`);
return response.data || [];
};
export const checkUserResetState = async () => {
const response = await api.get('/user/state');
return response.data.resetState;
};
export default api;

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,649 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Layout } from '../components/Layout';
import { Button } from '../components/Button';
import { Plus, Edit2, Trash2, Lock, Unlock, Users, ChevronDown, ChevronUp, Eye, X } from 'lucide-react';
import { QuestionForm } from '../components/QuestionForm';
import {
getAdminQuestionSets,
getAdminUsers,
getAdminQuestions,
getAdminResponses,
createQuestionSet,
updateQuestionSetName,
updateQuestionSetOrder,
toggleQuestionSetLock,
deleteQuestionSet,
createUser,
updateUser,
deleteUser,
resetUser,
createQuestion,
updateQuestion,
deleteQuestion,
} from '../lib/api';
export const AdminDashboard: React.FC = () => {
const queryClient = useQueryClient();
const [newQuestionSetName, setNewQuestionSetName] = useState('');
const [newUserName, setNewUserName] = useState('');
const [showUserModal, setShowUserModal] = useState(false);
const [selectedQuestionSet, setSelectedQuestionSet] = useState<string | null>(null);
const [showResponsesModal, setShowResponsesModal] = useState(false);
const [selectedQuestion, setSelectedQuestion] = useState<string | null>(null);
const [showQuestionModal, setShowQuestionModal] = useState(false);
const [currentQuestionSetId, setCurrentQuestionSetId] = useState<string | null>(null);
const [editingQuestion, setEditingQuestion] = useState<any | null>(null);
const [showQuestionSetModal, setShowQuestionSetModal] = useState(false);
const [editingQuestionSet, setEditingQuestionSet] = useState<any | null>(null);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [editedUserName, setEditedUserName] = useState('');
const { data: questionSets, isLoading: loadingQuestionSets } = useQuery({
queryKey: ['adminQuestionSets'],
queryFn: getAdminQuestionSets,
});
const { data: users, isLoading: loadingUsers } = useQuery({
queryKey: ['adminUsers'],
queryFn: getAdminUsers,
});
const { data: questions } = useQuery({
queryKey: ['adminQuestions', selectedQuestionSet],
queryFn: () => selectedQuestionSet ? getAdminQuestions(selectedQuestionSet) : Promise.resolve(null),
enabled: !!selectedQuestionSet,
});
const { data: responses } = useQuery({
queryKey: ['adminResponses', selectedQuestion],
queryFn: () => selectedQuestion ? getAdminResponses(selectedQuestion) : Promise.resolve(null),
enabled: !!selectedQuestion,
});
const createQuestionSetMutation = useMutation({
mutationFn: createQuestionSet,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
setNewQuestionSetName('');
},
});
const updateQuestionSetNameMutation = useMutation({
mutationFn: ({ questionSetId, newName }: { questionSetId: string; newName: string }) =>
updateQuestionSetName(questionSetId, newName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
},
});
const updateQuestionSetOrderMutation = useMutation({
mutationFn: ({ questionSetId, questionSetOrder }: { questionSetId: string; questionSetOrder: number }) =>
updateQuestionSetOrder(questionSetId, questionSetOrder),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
},
});
const toggleLockMutation = useMutation({
mutationFn: ({ id, locked }: { id: string; locked: boolean }) =>
toggleQuestionSetLock(id, locked),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
},
});
const deleteQuestionSetMutation = useMutation({
mutationFn: deleteQuestionSet,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
},
});
const createQuestionMutation = useMutation({
mutationFn: (data: {
questionSetId: string;
questionText: string;
expectedResultText: string;
questionOrder: number;
minWordLength: number;
maxWordLength: number;
}) => createQuestion(
data.questionSetId,
data.questionText,
data.expectedResultText,
data.questionOrder,
data.minWordLength,
data.maxWordLength
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestions', selectedQuestionSet] });
},
});
const updateQuestionMutation = useMutation({
mutationFn: (data: {
questionId: string;
questionText: string;
expectedResultText: string;
questionOrder: number;
minWordLength: number;
maxWordLength: number;
}) => updateQuestion(
data.questionId,
data.questionText,
data.expectedResultText,
data.questionOrder,
data.minWordLength,
data.maxWordLength
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestions', selectedQuestionSet] });
},
});
const deleteQuestionMutation = useMutation({
mutationFn: deleteQuestion,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminQuestions', selectedQuestionSet] });
},
});
const createUserMutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
setNewUserName('');
},
});
const updateUserMutation = useMutation({
mutationFn: ({ userName, newUserName }: { userName: string; newUserName: string }) =>
updateUser(userName, newUserName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
},
});
const deleteUserMutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
},
});
const resetUserMutation = useMutation({
mutationFn: resetUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
},
});
const handleAddQuestion = (questionSetId: string) => {
setCurrentQuestionSetId(questionSetId);
setEditingQuestion(null);
setShowQuestionModal(true);
};
const handleEditQuestion = (question: any) => {
setEditingQuestion(question);
setCurrentQuestionSetId(question.questionSetId);
setShowQuestionModal(true);
};
const handleQuestionFormSubmit = (data: {
questionText: string;
expectedResultText: string;
minWordLength: number;
maxWordLength: number;
}) => {
if (editingQuestion) {
updateQuestionMutation.mutate({
questionId: editingQuestion.questionId,
questionText: data.questionText,
expectedResultText: data.expectedResultText,
questionOrder: editingQuestion.questionOrder,
minWordLength: data.minWordLength,
maxWordLength: data.maxWordLength,
});
} else if (currentQuestionSetId) {
createQuestionMutation.mutate({
questionSetId: currentQuestionSetId,
questionText: data.questionText,
expectedResultText: data.expectedResultText,
questionOrder: questions?.length || 0,
minWordLength: data.minWordLength,
maxWordLength: data.maxWordLength,
});
}
setShowQuestionModal(false);
};
const handleEditQuestionSet = (set: any) => {
setEditingQuestionSet(set);
setShowQuestionSetModal(true);
};
const handleQuestionSetFormSubmit = (data: {
questionSetName: string;
questionSetOrder: number;
}) => {
if (editingQuestionSet) {
if (data.questionSetName !== editingQuestionSet.questionSetName) {
updateQuestionSetNameMutation.mutate({
questionSetId: editingQuestionSet.questionSetId,
newName: data.questionSetName,
});
}
if (data.questionSetOrder !== editingQuestionSet.questionSetOrder) {
updateQuestionSetOrderMutation.mutate({
questionSetId: editingQuestionSet.questionSetId,
questionSetOrder: data.questionSetOrder,
});
}
}
setShowQuestionSetModal(false);
};
if (loadingQuestionSets || loadingUsers) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
</Layout>
);
}
return (
<Layout>
<div className="space-y-8">
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">Question Sets</h2>
<Button
onClick={() => setShowUserModal(true)}
className="flex items-center gap-2"
>
<Users className="w-4 h-4" />
Manage Users
</Button>
</div>
<div className="flex gap-4 mb-6">
<input
type="text"
value={newQuestionSetName}
onChange={(e) => setNewQuestionSetName(e.target.value)}
placeholder="New question set name"
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400"
/>
<Button
onClick={() => createQuestionSetMutation.mutate(newQuestionSetName)}
disabled={!newQuestionSetName.trim()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Set
</Button>
</div>
<div className="grid gap-4">
{questionSets?.map((set) => (
<div
key={set.questionSetId}
className="bg-gray-800 rounded-lg overflow-hidden"
>
<div className="p-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">
{set.questionSetName}
</h3>
<div className="flex items-center gap-4 mt-1">
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => handleEditQuestionSet(set)}
>
Order: {set.questionSetOrder}
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() =>
toggleLockMutation.mutate({
id: set.questionSetId,
locked: !set.locked,
})
}
>
{set.locked ? (
<Lock className="w-4 h-4" />
) : (
<Unlock className="w-4 h-4" />
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleEditQuestionSet(set)}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() =>
deleteQuestionSetMutation.mutate(set.questionSetId)
}
>
<Trash2 className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setSelectedQuestionSet(selectedQuestionSet === set.questionSetId ? null : set.questionSetId)}
>
{selectedQuestionSet === set.questionSetId ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
</div>
</div>
{selectedQuestionSet === set.questionSetId && (
<div className="border-t border-gray-700 p-4">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-semibold text-white">Questions</h4>
<Button
size="sm"
onClick={() => handleAddQuestion(set.questionSetId)}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Question
</Button>
</div>
<div className="space-y-4">
{questions?.map((question) => (
<div
key={question.questionId}
className="bg-gray-700 p-4 rounded-lg"
>
<div className="flex justify-between items-start">
<div>
<p className="text-white font-medium mb-2">{question.questionText}</p>
<p className="text-gray-400 text-sm">
Word limit: {question.minWordLength}-{question.maxWordLength}
</p>
<p className="text-gray-400 text-sm mt-1">
Expected result: {question.expectedResultText}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedQuestion(question.questionId);
setShowResponsesModal(true);
}}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleEditQuestion(question)}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => deleteQuestionMutation.mutate(question.questionId)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
{showUserModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-2xl">
<h2 className="text-xl font-bold text-white mb-4">User Management</h2>
<div className="flex gap-4 mb-6">
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="New username"
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400"
/>
<Button
onClick={() => createUserMutation.mutate(newUserName)}
disabled={!newUserName.trim()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add User
</Button>
</div>
<div className="space-y-4 max-h-96 overflow-y-auto">
{users?.map((user) => (
<div
key={user.username}
className="bg-gray-700 p-4 rounded-lg flex items-center justify-between"
>
<div>
{editingUserId === user.username ? (
<input
type="text"
value={editedUserName}
onChange={(e) => setEditedUserName(e.target.value)}
onBlur={() => {
if (editedUserName.trim() !== '' && editedUserName !== user.username) {
updateUserMutation.mutate({
userName: user.username,
newUserName: editedUserName.trim()
});
}
setEditingUserId(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setEditingUserId(null);
}
}}
autoFocus
className="bg-transparent border-b border-blue-500 font-semibold text-white focus:outline-none"
/>
) : (
<p
className="font-semibold text-white cursor-pointer hover:text-blue-400"
onClick={() => {
setEditingUserId(user.username);
setEditedUserName(user.username);
}}
>
{user.username}
</p>
)}
<p className="text-sm text-gray-400">PIN: {user.pin}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => resetUserMutation.mutate(user.username)}
>
Reset
</Button>
<Button
variant="danger"
size="sm"
onClick={() => deleteUserMutation.mutate(user.username)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
<div className="mt-6 flex justify-end">
<Button
variant="secondary"
onClick={() => setShowUserModal(false)}
>
Close
</Button>
</div>
</div>
</div>
)}
{showResponsesModal && selectedQuestion && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl">
<h2 className="text-xl font-bold text-white mb-4">Student Responses</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr>
<th className="text-left p-2 text-gray-400">Student</th>
<th className="text-left p-2 text-gray-400">Response</th>
<th className="text-left p-2 text-gray-400">Timestamp</th>
</tr>
</thead>
<tbody>
{responses?.map((response) => (
<tr key={response.responseId} className="border-t border-gray-700">
<td className="p-2 text-white">{response.userName}</td>
<td className="p-2 text-white">{response.responseText}</td>
<td className="p-2 text-gray-400">
{new Date(response.responseTime).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-6 flex justify-end">
<Button
variant="secondary"
onClick={() => {
setShowResponsesModal(false);
setSelectedQuestion(null);
}}
>
Close
</Button>
</div>
</div>
</div>
)}
{showQuestionModal && (
<QuestionForm
onSubmit={handleQuestionFormSubmit}
onCancel={() => setShowQuestionModal(false)}
initialData={editingQuestion ? {
questionText: editingQuestion.questionText,
expectedResultText: editingQuestion.expectedResultText,
minWordLength: editingQuestion.minWordLength,
maxWordLength: editingQuestion.maxWordLength
} : undefined}
/>
)}
{showQuestionSetModal && editingQuestionSet && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-md relative z-10">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-white">
Edit Question Set
</h2>
<Button variant="secondary" size="sm" onClick={() => setShowQuestionSetModal(false)}>
<X className="w-4 h-4" />
</Button>
</div>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
handleQuestionSetFormSubmit({
questionSetName: formData.get('questionSetName') as string,
questionSetOrder: parseInt(formData.get('questionSetOrder') as string)
});
}} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Question Set Name
</label>
<input
type="text"
name="questionSetName"
defaultValue={editingQuestionSet.questionSetName}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Order
</label>
<input
type="number"
name="questionSetOrder"
defaultValue={editingQuestionSet.questionSetOrder}
min="1"
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors"
required
/>
</div>
<div className="flex justify-end gap-4 mt-6">
<Button variant="secondary" onClick={() => setShowQuestionSetModal(false)}>
Cancel
</Button>
<Button type="submit">
Save Changes
</Button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);
};

49
src/pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,49 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Layout } from '../components/Layout';
import { QuestionSetCard } from '../components/QuestionSetCard';
import { getQuestionSets } from '../lib/api';
export const Dashboard: React.FC = () => {
const navigate = useNavigate();
const { data: questionSets, isLoading, error } = useQuery({
queryKey: ['questionSets'],
queryFn: getQuestionSets,
});
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
</Layout>
);
}
if (error) {
return (
<Layout>
<div className="text-center text-red-500">
Failed to load question sets. Please try again later.
</div>
</Layout>
);
}
return (
<Layout>
<h2 className="text-2xl font-bold text-white mb-6">Available Question Sets</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{questionSets?.map((questionSet) => (
<QuestionSetCard
key={questionSet.questionSetId}
questionSet={questionSet}
onClick={() => navigate(`/quiz/${questionSet.questionSetId}`)}
/>
))}
</div>
</Layout>
);
};

66
src/pages/Login.tsx Normal file
View file

@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Brain } from 'lucide-react';
import { CodeInput } from '../components/CodeInput';
import { Button } from '../components/Button';
import { login, adminLogin } from '../lib/api';
import { useAuthStore } from '../store/auth';
export const Login: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const navigate = useNavigate();
const setToken = useAuthStore(state => state.setToken);
const handleLogin = async (code: string) => {
try {
setError(null);
const response = isAdmin
? await adminLogin(code)
: await login(code);
if (response.success) {
setToken(response.accessToken, isAdmin);
navigate(isAdmin ? '/admin' : '/dashboard');
} else {
setError(response.errorMessage || 'Login failed');
}
} catch (err) {
setError('An error occurred. Please try again.');
}
};
return (
<div className="min-h-screen bg-gray-900 flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Brain className="w-16 h-16 text-blue-500 mx-auto mb-4" />
<h1 className="text-4xl font-bold text-white mb-2">QuizConnect</h1>
<p className="text-gray-400">Enter your {isAdmin ? 'admin' : 'user'} code to continue</p>
</div>
<div className="bg-gray-800 rounded-lg p-8 shadow-xl">
<div className="flex justify-center mb-8">
<CodeInput onComplete={handleLogin} />
</div>
{error && (
<div className="text-red-500 text-center mb-4">
{error}
</div>
)}
<div className="flex justify-center">
<Button
variant="secondary"
onClick={() => setIsAdmin(!isAdmin)}
className="mt-4"
>
Switch to {isAdmin ? 'User' : 'Admin'} Login
</Button>
</div>
</div>
</div>
</div>
);
};

190
src/pages/Quiz.tsx Normal file
View file

@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Layout } from '../components/Layout';
import { QuestionCard } from '../components/QuestionCard';
import { Button } from '../components/Button';
import { Home, ChevronLeft, ChevronRight, Check } from 'lucide-react';
import { getQuestions, submitResponse, getQuestionSets, getUserResponses } from '../lib/api';
export const Quiz: React.FC = () => {
const { questionSetId } = useParams<{ questionSetId: string }>();
const navigate = useNavigate();
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answeredQuestions, setAnsweredQuestions] = useState<Set<number>>(new Set());
const { data: questions, isLoading: loadingQuestions } = useQuery({
queryKey: ['questions', questionSetId],
queryFn: () => getQuestions(questionSetId!),
});
const { data: questionSets } = useQuery({
queryKey: ['questionSets'],
queryFn: getQuestionSets,
});
// Get user responses for current question
const { data: userResponseData } = useQuery({
queryKey: ['userResponses', questions?.[currentQuestionIndex]?.questionId],
queryFn: () => questions?.[currentQuestionIndex]
? getUserResponses(questions[currentQuestionIndex].questionId)
: Promise.resolve(null),
enabled: !!questions?.[currentQuestionIndex],
});
// Extract actual response from the data structure
const userResponse = userResponseData?.responses?.response?.[0]?.responseText || '';
const hasResponse = !!userResponseData?.responses?.response?.length;
// Reset current question index when questionset changes
useEffect(() => {
setCurrentQuestionIndex(0);
setAnsweredQuestions(new Set());
}, [questionSetId]);
// Check if questions are already answered
useEffect(() => {
if (hasResponse) {
setAnsweredQuestions(prev => new Set([...prev, currentQuestionIndex]));
}
}, [hasResponse, currentQuestionIndex]);
// Check if all questions are answered
const allQuestionsAnswered = questions && questions.length > 0 &&
answeredQuestions.size === questions.length;
const handleSubmit = async (response: string) => {
if (!questions) return;
await submitResponse(questions[currentQuestionIndex].questionId, response);
setAnsweredQuestions(prev => new Set([...prev, currentQuestionIndex]));
// Automatically navigate to next question after answering
if (currentQuestionIndex < questions.length - 1) {
handleNavigate('next');
}
};
const handleNavigate = (direction: 'prev' | 'next') => {
if (!questions) return;
if (direction === 'prev' && currentQuestionIndex > 0) {
setCurrentQuestionIndex(currentQuestionIndex - 1);
} else if (direction === 'next' && currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
}
};
const handleDone = () => {
// Check for next set or navigate to dashboard
if (!questionSets) return;
const currentSetIndex = questionSets.findIndex((set: { questionSetId: string | undefined; }) => set.questionSetId === questionSetId);
const nextSet = questionSets
.slice(currentSetIndex + 1)
.find((set: { locked: any; }) => !set.locked);
if (nextSet) {
navigate(`/quiz/${nextSet.questionSetId}`);
} else {
navigate('/dashboard', {
state: {
message: 'Congratulations! You have completed all available question sets.'
}
});
}
};
if (loadingQuestions) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
</Layout>
);
}
if (!questions || questions.length === 0) {
return (
<Layout>
<div className="text-center text-red-500">
Failed to load questions. Please try again later.
</div>
</Layout>
);
}
return (
<Layout>
<div className="max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Button
variant="secondary"
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
Home
</Button>
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={() => handleNavigate('prev')}
disabled={currentQuestionIndex === 0}
className={`flex items-center gap-2 ${currentQuestionIndex === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
{allQuestionsAnswered ? (
<Button
variant="primary"
onClick={handleDone}
className="flex items-center gap-2"
>
<Check className="w-4 h-4" />
Done
</Button>
) : (
<Button
variant="secondary"
onClick={() => handleNavigate('next')}
disabled={currentQuestionIndex === questions.length - 1}
className="flex items-center gap-2"
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
)}
</div>
</div>
<div className="mb-6">
<div className="flex items-center justify-between text-sm text-gray-400 mb-2">
<span>Progress: {answeredQuestions.size} of {questions.length} questions completed</span>
<span>Question {currentQuestionIndex + 1} of {questions.length}</span>
</div>
<div className="h-2 bg-gray-700 rounded-full">
<div
className="h-2 bg-blue-500 rounded-full transition-all duration-300"
style={{ width: `${(answeredQuestions.size / questions.length) * 100}%` }}
/>
</div>
</div>
{questions[currentQuestionIndex] && (
<QuestionCard
question={questions[currentQuestionIndex]}
onSubmit={handleSubmit}
currentNumber={currentQuestionIndex + 1}
totalQuestions={questions.length}
isAnswered={answeredQuestions.has(currentQuestionIndex)}
previousResponse={userResponse}
/>
)}
</div>
</Layout>
);
};

29
src/store/auth.ts Normal file
View file

@ -0,0 +1,29 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
token: string | null;
isAdmin: boolean;
setToken: (token: string, isAdmin: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
isAdmin: false,
setToken: (token, isAdmin) => {
localStorage.setItem('token', token);
set({ token, isAdmin });
},
logout: () => {
localStorage.removeItem('token');
set({ token: null, isAdmin: false });
},
}),
{
name: 'auth-storage',
}
)
);

36
src/types/api.ts Normal file
View file

@ -0,0 +1,36 @@
export interface LoginResponse {
success: boolean;
accessToken: string;
errorMessage: string | null;
}
export interface QuestionSet {
questionSetId: string;
questionSetName: string;
questionSetOrder: number;
locked: boolean;
}
export interface Question {
questionId: string;
questionSetId: string;
questionText: string;
expectedResultText: string;
questionOrder: number;
minWordLength: number;
maxWordLength: number;
}
export interface Response {
responseId: string;
questionId: string;
userName: string;
responseText: string;
timestamp: string;
}
export interface User {
username: string;
pin: string;
resetState: boolean;
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

16
tailwind.config.js Normal file
View file

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
background: '#1a1a1a',
card: '#2d2d2d',
primary: '#007bff',
success: '#28a745',
error: '#dc3545',
},
},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});