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