mirror of
https://github.com/Kizuren/QuizConnect.git
synced 2025-12-21 21:16:14 +01:00
Initial commit
This commit is contained in:
commit
ea82926500
58 changed files with 9323 additions and 0 deletions
41
.github/workflows/build.yml
vendored
Normal file
41
.github/workflows/build.yml
vendored
Normal 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
160
.gitignore
vendored
Normal 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
52
README.md
Normal 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
25
Server/.dockerignore
Normal 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
22
Server/Dockerfile
Normal 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
21
Server/Server.sln
Normal 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
|
||||
3
Server/Server/.env.example
Normal file
3
Server/Server/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
MONGODB_URI=mongodb://user:password@localhost:27017/database
|
||||
ADMIN_LOGIN_ID=1B3z
|
||||
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
|
||||
479
Server/Server/Controllers/AdminController.cs
Normal file
479
Server/Server/Controllers/AdminController.cs
Normal 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
|
||||
}
|
||||
283
Server/Server/Controllers/UserController.cs
Normal file
283
Server/Server/Controllers/UserController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
191
Server/Server/Models/AdminApiModels.cs
Normal file
191
Server/Server/Models/AdminApiModels.cs
Normal 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
|
||||
54
Server/Server/Models/QuestionModels.cs
Normal file
54
Server/Server/Models/QuestionModels.cs
Normal 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; } = [];
|
||||
}
|
||||
94
Server/Server/Models/UserApiModels.cs
Normal file
94
Server/Server/Models/UserApiModels.cs
Normal 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
|
||||
21
Server/Server/Models/UserModels.cs
Normal file
21
Server/Server/Models/UserModels.cs
Normal 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
87
Server/Server/Program.cs
Normal 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();
|
||||
|
||||
23
Server/Server/Properties/launchSettings.json
Normal file
23
Server/Server/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Server/Server/Server.csproj
Normal file
23
Server/Server/Server.csproj
Normal 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
224
Server/Server/Server.http
Normal 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
24
Server/Server/Server.sln
Normal 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
|
||||
22
Server/Server/Services/AdminTokenService.cs
Normal file
22
Server/Server/Services/AdminTokenService.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
62
Server/Server/Services/DiscordNotificationService.cs
Normal file
62
Server/Server/Services/DiscordNotificationService.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
246
Server/Server/Services/QuestionManagementService.cs
Normal file
246
Server/Server/Services/QuestionManagementService.cs
Normal 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
|
||||
}
|
||||
138
Server/Server/Services/UserManagementService.cs
Normal file
138
Server/Server/Services/UserManagementService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
60
Server/Server/Services/UserTokenService.cs
Normal file
60
Server/Server/Services/UserTokenService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Server/Server/Utils/GlobalExceptionHandlerMiddleware.cs
Normal file
59
Server/Server/Utils/GlobalExceptionHandlerMiddleware.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
32
Server/Server/Utils/TokenGenerator.cs
Normal file
32
Server/Server/Utils/TokenGenerator.cs
Normal 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
594
Server/Server/apiDoc.md
Normal 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
|
||||
}
|
||||
```
|
||||
12
Server/Server/appsettings.Development.json
Normal file
12
Server/Server/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"DefaultAdminUserName": "admin",
|
||||
"DefaultAdminPassword": "password",
|
||||
"IdLength": 8,
|
||||
"TokenLength": 1024
|
||||
}
|
||||
13
Server/Server/appsettings.json
Normal file
13
Server/Server/appsettings.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"DefaultAdminUserName": "admin",
|
||||
"DefaultAdminPassword": "password",
|
||||
"IdLength": 8,
|
||||
"TokenLength": 1024
|
||||
}
|
||||
8
Server/Server/http-client.private.env.json
Normal file
8
Server/Server/http-client.private.env.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"dev": {
|
||||
"loginAdmin": "admin123",
|
||||
"loginUser": "value",
|
||||
"createQuestionSet": "value",
|
||||
"createQuestion": "value"
|
||||
}
|
||||
}
|
||||
12
Server/docker-compose-build.yaml
Normal file
12
Server/docker-compose-build.yaml
Normal 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
|
||||
11
Server/docker-compose.yaml
Normal file
11
Server/docker-compose.yaml
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
4355
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
30
src/App.tsx
Normal file
30
src/App.tsx
Normal 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
36
src/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
src/components/CodeInput.tsx
Normal file
56
src/components/CodeInput.tsx
Normal 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
76
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
99
src/components/QuestionCard.tsx
Normal file
99
src/components/QuestionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
165
src/components/QuestionForm.tsx
Normal file
165
src/components/QuestionForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/components/QuestionSetCard.tsx
Normal file
32
src/components/QuestionSetCard.tsx
Normal 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
15
src/index.css
Normal 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
172
src/lib/api.ts
Normal 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
10
src/main.tsx
Normal 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>
|
||||
);
|
||||
649
src/pages/AdminDashboard.tsx
Normal file
649
src/pages/AdminDashboard.tsx
Normal 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
49
src/pages/Dashboard.tsx
Normal 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
66
src/pages/Login.tsx
Normal 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
190
src/pages/Quiz.tsx
Normal 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
29
src/store/auth.ts
Normal 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
36
src/types/api.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
16
tailwind.config.js
Normal file
16
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
7
vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue