Init repo

This commit is contained in:
Stedoss
2026-07-27 19:21:52 +01:00
commit bb6a1159ec
60 changed files with 8277 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+1
View File
@@ -0,0 +1 @@
**/Migrations
+8
View File
@@ -0,0 +1,8 @@
root = true
[*]
indent_style = space
insert_final_newline = true
[*.yml]
indent_size = 2
+604
View File
@@ -0,0 +1,604 @@
# This file has sections for Visual Studio, Jetbrains IDEs (intellij, rider etc) and Node/JS/TS
# Please delete sections if they are not relevant
# =========================== #
# Covers Visual Studio / .NET #
# =========================== #
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
.idea/
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# 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
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# ===================== #
# Covers JetBrains IDEs #
# ===================== #
# IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# =========================== #
# Covers Node, JS, typescript #
# =========================== #
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
.terraformrc
terraform.rc
+15
View File
@@ -0,0 +1,15 @@
# Decisions
Fill this in before you send your submission back — a few minutes is plenty. It is the artifact we pay the most attention to, and it is what we will dig into together at the next stage.
## What I built and why
<!-- What you chose to build, the trade-offs you made, anything you deliberately left out. -->
## Where AI helped, and what I accepted or rejected
<!-- If you used AI (we do), tell us how: what it produced, what you kept, what you threw away and why. "I didn't use AI" is also a fine answer. -->
## What I would do differently with more time
<!-- The things you would tackle next, or do properly. -->
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+95
View File
@@ -0,0 +1,95 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MissionControl.Domain", "src\MissionControl.Domain\MissionControl.Domain.csproj", "{C9E55B1D-C3A5-405C-B918-386715D6B7D9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MissionControl.Data", "src\MissionControl.Data\MissionControl.Data.csproj", "{72D37806-9577-4A96-9179-6E6EAE089E84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MissionControl.Server", "src\MissionControl.Server\MissionControl.Server.csproj", "{8FC7C9F9-5863-423E-B90C-239E809659B7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MissionControl.Server.Tests", "test\MissionControl.Server.Tests\MissionControl.Server.Tests.csproj", "{C41D829D-68C4-4035-B289-B3883F99E95F}"
EndProject
Project("{54A90642-561A-4BB1-A94E-469ADEE60C69}") = "mission-control-client", "src\mission-control-client\mission-control-client.esproj", "{84896A94-5238-4F3F-A04A-315FD901C52E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Debug|x64.ActiveCfg = Debug|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Debug|x64.Build.0 = Debug|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Debug|x86.ActiveCfg = Debug|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Debug|x86.Build.0 = Debug|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Release|Any CPU.Build.0 = Release|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Release|x64.ActiveCfg = Release|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Release|x64.Build.0 = Release|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Release|x86.ActiveCfg = Release|Any CPU
{C9E55B1D-C3A5-405C-B918-386715D6B7D9}.Release|x86.Build.0 = Release|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Debug|x64.ActiveCfg = Debug|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Debug|x64.Build.0 = Debug|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Debug|x86.ActiveCfg = Debug|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Debug|x86.Build.0 = Debug|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Release|Any CPU.Build.0 = Release|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Release|x64.ActiveCfg = Release|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Release|x64.Build.0 = Release|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Release|x86.ActiveCfg = Release|Any CPU
{72D37806-9577-4A96-9179-6E6EAE089E84}.Release|x86.Build.0 = Release|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Debug|x64.ActiveCfg = Debug|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Debug|x64.Build.0 = Debug|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Debug|x86.ActiveCfg = Debug|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Debug|x86.Build.0 = Debug|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Release|Any CPU.Build.0 = Release|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Release|x64.ActiveCfg = Release|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Release|x64.Build.0 = Release|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Release|x86.ActiveCfg = Release|Any CPU
{8FC7C9F9-5863-423E-B90C-239E809659B7}.Release|x86.Build.0 = Release|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Debug|x64.ActiveCfg = Debug|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Debug|x64.Build.0 = Debug|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Debug|x86.ActiveCfg = Debug|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Debug|x86.Build.0 = Debug|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Release|Any CPU.Build.0 = Release|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Release|x64.ActiveCfg = Release|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Release|x64.Build.0 = Release|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Release|x86.ActiveCfg = Release|Any CPU
{C41D829D-68C4-4035-B289-B3883F99E95F}.Release|x86.Build.0 = Release|Any CPU
{84896A94-5238-4F3F-A04A-315FD901C52E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84896A94-5238-4F3F-A04A-315FD901C52E}.Debug|x64.ActiveCfg = Debug|Any CPU
{84896A94-5238-4F3F-A04A-315FD901C52E}.Debug|x86.ActiveCfg = Debug|Any CPU
{84896A94-5238-4F3F-A04A-315FD901C52E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84896A94-5238-4F3F-A04A-315FD901C52E}.Release|x64.ActiveCfg = Release|Any CPU
{84896A94-5238-4F3F-A04A-315FD901C52E}.Release|x86.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C9E55B1D-C3A5-405C-B918-386715D6B7D9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{72D37806-9577-4A96-9179-6E6EAE089E84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{8FC7C9F9-5863-423E-B90C-239E809659B7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C41D829D-68C4-4035-B289-B3883F99E95F} = {0C88DD14-F956-CE84-757C-A364CCF449FC}
{84896A94-5238-4F3F-A04A-315FD901C52E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
+99
View File
@@ -0,0 +1,99 @@
# Mission Control 🚀
Welcome! This is the starter repo for the X-Lab take-home exercise.
Mission Control is a small app for running a (fictional) space programme. It is built the way we build software at X-Lab: a C#/.NET API using FastEndpoints and Entity Framework Core on SQL Server, and a React + TypeScript front end using Vite, TanStack Router/Query, and Mantine.
The app currently does one thing: it tracks **missions**. The full vertical slice for missions — endpoint, validation, data access, UI, and tests work out of the box.
## Your task
Mission Control can plan missions, but nothing ever lifts off. The flight team wants to schedule **launches**.
Build the launches feature end to end. Requirements:
- A launch belongs to a mission and has, at minimum, a scheduled time and a status.
- Launches can be created and viewed in the UI.
- Invalid input is rejected sensibly.
- At least one test, wherever you think it adds the most value.
Everything else is your call: the fields, the routes, the UX, what "sensible validation" means, how launches relate to missions. The missions slice shows you our patterns — follow them, or deliberately diverge and tell us why in DECISIONS.md.
If you finish the core and want to keep going, there is an **optional extension**: take the app in a direction that shows us something you care about. More features, refactor, nicer UX, more tests, infrastructure (see [terraform/](terraform/)), whatever you like. This is genuinely optional.
## Time
We would much rather see a thoughtful, unfinished hour than a polished weekend, we will talk through what you built together at the next stage.
## AI
Use whatever tools you use day to day, **including AI — we do**. We are not testing whether you can code without it. What we care about is the judgement on top: what you chose to build, what you accepted or rejected, and whether you understand what you shipped.
## DECISIONS.md
Before you send your submission back, spend a few extra minutes filling in [DECISIONS.md](DECISIONS.md). We will keep this open and read as we review your submission.
## Getting started
You will need:
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
- [Node 20+](https://nodejs.org/)
- [Docker](https://www.docker.com/products/docker-desktop/)
Then:
```bash
# 1. Start SQL Server
docker compose up -d
# 2. Install front-end dependencies
npm install --prefix src/mission-control-client
# 3. Run the app (starts the API and the Vite dev server together)
dotnet run --project src/MissionControl.Server
```
****
Open http://localhost:5173 — you should see the seeded missions. Database migrations and seed data apply automatically on startup.
Swagger UI is at http://localhost:5010/swagger.
> **Apple Silicon:** the SQL Server image is x64 and runs via Rosetta. If the container fails to start, enable "Use Rosetta for x86_64/amd64 emulation" in Docker Desktop → Settings → General.
## Running the tests
```bash
dotnet test
npm test --prefix src/mission-control-client
```
## The lay of the land
| Path | What it is |
| --- | --- |
| `src/MissionControl.Server` | The API host. Endpoints live in `Endpoints/<Feature>/`, one file per operation with its request, response, and validator alongside. |
| `src/MissionControl.Domain` | Domain entities. |
| `src/MissionControl.Data` | EF Core: DbContext, entity configurations, migrations, dev seed data. |
| `src/mission-control-client` | The React app. File-based routes in `src/routes/`, API calls and types in `src/api/`. |
| `test/MissionControl.Server.Tests` | xUnit + Shouldly + NSubstitute, with example endpoint and validator tests (endpoints test against in-memory SQLite via `TestDatabase`). |
| `terraform/` | A small, optional Azure skeleton. Not needed to run anything locally. |
## Useful commands
Add an EF migration:
```bash
dotnet ef migrations add AddLaunches --project src/MissionControl.Data --startup-project src/MissionControl.Server
```
Reset the database (the container has no volume, so this starts fresh):
```bash
docker compose down && docker compose up -d
```
## Submitting
Send back either a zip of the repo (please leave out `node_modules`, `bin`, and `obj`) or a link to a private repo — whichever is easier. If you use git as you go, keep the history; we enjoy reading it.
If anything in the starter is broken or confusing, please let us know.
+15
View File
@@ -0,0 +1,15 @@
services:
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=MissionControl!23
ports:
- "1433:1433"
healthcheck:
test: /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1" -b -o /dev/null
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
+15
View File
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MissionControl.Data;
public static class Composition
{
public static IServiceCollection AddMissionControlData(
this IServiceCollection services,
string connectionString
) =>
services.AddDbContext<MissionControlContext>(options =>
options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure())
);
}
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MissionControl.Domain.Entities;
namespace MissionControl.Data;
public static class DevelopmentDatabase
{
public static async Task MigrateAndSeedAsync(
IServiceProvider services,
CancellationToken ct = default
)
{
using var scope = services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<MissionControlContext>();
await context.Database.MigrateAsync(ct);
if (await context.Missions.AnyAsync(ct))
return;
context.Missions.AddRange(
new Mission
{
Name = "Artemis Echo",
Description = "Resupply run to the lunar gateway station.",
Status = MissionStatus.Active,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-40),
},
new Mission
{
Name = "Kuiper Courier",
Description = "Sample return from the outer edge of the Kuiper belt.",
Status = MissionStatus.Active,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-31),
},
new Mission
{
Name = "Red Horizon",
Description = "First crewed survey of the Valles Marineris canyon system.",
Status = MissionStatus.Planned,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-12),
},
new Mission
{
Name = "Solar Sailor",
Description = "Prototype solar sail shakedown cruise around Venus.",
Status = MissionStatus.Planned,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-3),
},
new Mission
{
Name = "Juno Reborn",
Description = "Recover and reboot a decommissioned Jupiter probe.",
Status = MissionStatus.Completed,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-90),
}
);
await context.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MissionControl.Domain.Entities;
namespace MissionControl.Data.EntityConfigurations;
public class MissionConfiguration : IEntityTypeConfiguration<Mission>
{
public void Configure(EntityTypeBuilder<Mission> builder)
{
builder.Property(m => m.Name).HasMaxLength(100);
builder.Property(m => m.Description).HasMaxLength(500);
builder.Property(m => m.Status).HasConversion<string>().HasMaxLength(20);
}
}
@@ -0,0 +1,58 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MissionControl.Data;
#nullable disable
namespace MissionControl.Data.Migrations
{
[DbContext(typeof(MissionControlContext))]
[Migration("20260721084606_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MissionControl.Domain.Entities.Mission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.ToTable("Missions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MissionControl.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Missions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(
type: "nvarchar(100)",
maxLength: 100,
nullable: false
),
Description = table.Column<string>(
type: "nvarchar(500)",
maxLength: 500,
nullable: true
),
Status = table.Column<string>(
type: "nvarchar(20)",
maxLength: 20,
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "datetimeoffset",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Missions", x => x.Id);
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Missions");
}
}
}
@@ -0,0 +1,55 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MissionControl.Data;
#nullable disable
namespace MissionControl.Data.Migrations
{
[DbContext(typeof(MissionControlContext))]
partial class MissionControlContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MissionControl.Domain.Entities.Mission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.ToTable("Missions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MissionControl.Domain\MissionControl.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
using MissionControl.Domain.Entities;
namespace MissionControl.Data;
public class MissionControlContext(DbContextOptions<MissionControlContext> options)
: DbContext(options)
{
public DbSet<Mission> Missions => Set<Mission>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MissionControlContext).Assembly);
}
@@ -0,0 +1,14 @@
namespace MissionControl.Domain.Entities;
public class Mission
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Name { get; set; }
public string? Description { get; set; }
public MissionStatus Status { get; set; } = MissionStatus.Planned;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,8 @@
namespace MissionControl.Domain.Entities;
public enum MissionStatus
{
Planned,
Active,
Completed,
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"></Project>
@@ -0,0 +1,26 @@
using MissionControl.Domain.Entities;
namespace MissionControl.Server.Endpoints.Missions;
public record MissionResponse
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required MissionStatus Status { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public static MissionResponse FromDomain(Mission mission) =>
new()
{
Id = mission.Id,
Name = mission.Name,
Description = mission.Description,
Status = mission.Status,
CreatedAt = mission.CreatedAt,
};
}
@@ -0,0 +1,41 @@
using FastEndpoints;
using FluentValidation;
using MissionControl.Data;
using MissionControl.Domain.Entities;
namespace MissionControl.Server.Endpoints.Missions;
public class CreateMission(MissionControlContext dbContext)
: Endpoint<CreateMissionRequest, MissionResponse>
{
public override void Configure()
{
Post("/missions");
}
public override async Task HandleAsync(CreateMissionRequest req, CancellationToken ct)
{
var mission = new Mission { Name = req.Name, Description = req.Description };
dbContext.Missions.Add(mission);
await dbContext.SaveChangesAsync(ct);
await Send.OkAsync(MissionResponse.FromDomain(mission), ct);
}
}
public record CreateMissionRequest
{
public required string Name { get; init; }
public string? Description { get; init; }
}
public class CreateMissionRequestValidator : Validator<CreateMissionRequest>
{
public CreateMissionRequestValidator()
{
RuleFor(r => r.Name).NotEmpty().MaximumLength(100);
RuleFor(r => r.Description).MaximumLength(500);
}
}
@@ -0,0 +1,34 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using MissionControl.Data;
namespace MissionControl.Server.Endpoints.Missions;
public class ListMissions(MissionControlContext dbContext)
: EndpointWithoutRequest<ListMissionsResponse>
{
public override void Configure()
{
Get("/missions");
}
public override async Task HandleAsync(CancellationToken ct)
{
var missions = await dbContext
.Missions.AsNoTracking()
.OrderByDescending(m => m.CreatedAt)
.ToListAsync(ct);
var response = new ListMissionsResponse
{
Missions = missions.Select(MissionResponse.FromDomain).ToList(),
};
await Send.OkAsync(response, ct);
}
}
public record ListMissionsResponse
{
public required IReadOnlyList<MissionResponse> Missions { get; init; }
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<SpaRoot>..\mission-control-client</SpaRoot>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<SpaProxyServerUrl>http://localhost:5173</SpaProxyServerUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="8.2.0" />
<PackageReference Include="FastEndpoints.Swagger" Version="8.2.0" />
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MissionControl.Domain\MissionControl.Domain.csproj" />
<ProjectReference Include="..\MissionControl.Data\MissionControl.Data.csproj" />
<ProjectReference Include="..\mission-control-client\mission-control-client.esproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
using FastEndpoints;
using FastEndpoints.Swagger;
using MissionControl.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMissionControlData(
builder.Configuration.GetConnectionString("MissionControl")
?? throw new InvalidOperationException("Missing connection string 'MissionControl'")
);
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument(options => options.ShortSchemaNames = true);
var app = builder.Build();
app.UseDefaultExceptionHandler(useGenericReason: !app.Environment.IsDevelopment());
app.UseFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = "api";
config.Endpoints.ShortNames = true;
// The exercise ships without authentication, so every endpoint is anonymous.
config.Endpoints.Configurator = endpoint => endpoint.AllowAnonymous();
config.Serializer.Options.Converters.Add(new JsonStringEnumConverter());
});
if (app.Environment.IsDevelopment())
{
app.UseSwaggerGen();
await DevelopmentDatabase.MigrateAndSeedAsync(app.Services);
}
app.Run();
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"MissionControl.Server": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5010",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
}
}
}
}
@@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"MissionControl": "Server=localhost,1433;Database=MissionControl;User Id=sa;Password=MissionControl!23;TrustServerCertificate=True"
}
}
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,2 @@
dist
src/routeTree.gen.ts
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrderTypeScriptVersion": "5.9.3",
"singleQuote": true
}
@@ -0,0 +1,23 @@
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores(['dist', 'src/routeTree.gen.ts']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
},
]);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/rocket.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mission Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.784122">
<PropertyGroup>
<StartupCommand>npm run dev</StartupCommand>
<!-- Don't run npm install / npm run build as part of the .NET build -->
<ShouldRunBuildScript>false</ShouldRunBuildScript>
<ShouldRunNpmInstall>false</ShouldRunNpmInstall>
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
</PropertyGroup>
<!-- Targets needed to avoid MSB4057 -->
<!-- https://github.com/dotnet/sdk/issues/44587 -->
<!-- https://github.com/dotnet/sdk/issues/45760 -->
<Target Name="_GetRequiredWorkloads" />
<Target Name="pack" />
</Project>
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "mission-control-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier . --write",
"format:check": "prettier . --check",
"test": "vitest run",
"test:watch": "vitest",
"tsc": "tsc -b"
},
"dependencies": {
"@mantine/core": "^8.3.18",
"@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@tabler/icons-react": "^3.45.0",
"@tanstack/react-query": "^5.101.3",
"@tanstack/react-router": "^1.170.18",
"axios": "^1.18.1",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@tanstack/react-query-devtools": "^5.101.3",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.23",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.20",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.9.6",
"typescript": "~5.9.3",
"typescript-eslint": "^8.65.0",
"vite": "^7.3.6",
"vitest": "^3.2.7"
}
}
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚀</text></svg>

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,3 @@
import axios from 'axios';
export const apiClient = axios.create({ baseURL: '/api' });
@@ -0,0 +1,32 @@
import { apiClient } from './client';
export type MissionStatus = 'Planned' | 'Active' | 'Completed';
export type Mission = {
id: string;
name: string;
description: string | null;
status: MissionStatus;
createdAt: string;
};
export type CreateMissionRequest = {
name: string;
description?: string;
};
type ListMissionsResponse = {
missions: Mission[];
};
export async function listMissions(): Promise<Mission[]> {
const response = await apiClient.get<ListMissionsResponse>('/missions');
return response.data.missions;
}
export async function createMission(
request: CreateMissionRequest,
): Promise<Mission> {
const response = await apiClient.post<Mission>('/missions', request);
return response.data;
}
@@ -0,0 +1,16 @@
import { MantineProvider } from '@mantine/core';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MissionStatusBadge } from './MissionStatusBadge';
describe('MissionStatusBadge', () => {
it('shows the status label', () => {
render(
<MantineProvider>
<MissionStatusBadge status="Active" />
</MantineProvider>,
);
expect(screen.getByText('Active')).toBeInTheDocument();
});
});
@@ -0,0 +1,12 @@
import { Badge } from '@mantine/core';
import type { MissionStatus } from '../api/missions';
const statusColors: Record<MissionStatus, string> = {
Planned: 'blue',
Active: 'green',
Completed: 'gray',
};
export function MissionStatusBadge({ status }: { status: MissionStatus }) {
return <Badge color={statusColors[status]}>{status}</Badge>;
}
+29
View File
@@ -0,0 +1,29 @@
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { QueryClientProvider } from '@tanstack/react-query';
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { queryClient } from './queryClient';
import { routeTree } from './routeTree.gen';
const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MantineProvider>
<Notifications />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</MantineProvider>
</StrictMode>,
);
@@ -0,0 +1,13 @@
import { QueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) =>
isAxiosError(error) &&
error.response?.status.toString().startsWith('4') !== true &&
failureCount <= 2,
},
},
});
@@ -0,0 +1,77 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as MissionsRouteImport } from './routes/missions'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const MissionsRoute = MissionsRouteImport.update({
id: '/missions',
path: '/missions',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/missions': typeof MissionsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/missions': typeof MissionsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/missions': typeof MissionsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/missions'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/missions'
id: '__root__' | '/' | '/missions'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
MissionsRoute: typeof MissionsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/missions': {
id: '/missions'
path: '/missions'
fullPath: '/missions'
preLoaderRoute: typeof MissionsRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
MissionsRoute: MissionsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,26 @@
import { Anchor, AppShell, Group, Title } from '@mantine/core';
import { IconRocket } from '@tabler/icons-react';
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
export const Route = createRootRoute({
component: () => (
<AppShell header={{ height: 60 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md">
<Group gap="xs">
<IconRocket />
<Title order={3}>Mission Control</Title>
</Group>
<Group ml="xl" gap="md">
<Anchor component={Link} to="/missions">
Missions
</Anchor>
</Group>
</Group>
</AppShell.Header>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
),
});
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/missions' });
},
});
@@ -0,0 +1,144 @@
import {
Alert,
Button,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { createMission, listMissions } from '../api/missions';
import { MissionStatusBadge } from '../components/MissionStatusBadge';
export const Route = createFileRoute('/missions')({
component: MissionsPage,
});
function MissionsPage() {
return (
<Stack maw={900}>
<Title order={2}>Missions</Title>
<CreateMissionForm />
<MissionsTable />
</Stack>
);
}
function CreateMissionForm() {
const queryClient = useQueryClient();
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: '', description: '' },
validate: {
name: (value) => (value.trim().length === 0 ? 'Name is required' : null),
},
});
const createMissionMutation = useMutation({
mutationFn: createMission,
onSuccess: (mission) => {
notifications.show({
title: 'Mission created',
message: `${mission.name} is on the board.`,
color: 'green',
});
form.reset();
return queryClient.invalidateQueries({ queryKey: ['missions'] });
},
onError: () =>
notifications.show({
title: 'Something went wrong',
message: 'The mission could not be created.',
color: 'red',
}),
});
return (
<Paper withBorder p="md">
<form
onSubmit={form.onSubmit((values) =>
createMissionMutation.mutate({
name: values.name,
description: values.description || undefined,
}),
)}
>
<Stack>
<TextInput
label="Name"
placeholder="Orbital Bloom"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
/>
<Textarea
label="Description"
placeholder="What is this mission setting out to do?"
key={form.key('description')}
{...form.getInputProps('description')}
/>
<Group justify="flex-end">
<Button type="submit" loading={createMissionMutation.isPending}>
Create mission
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
function MissionsTable() {
const missionsQuery = useQuery({
queryKey: ['missions'],
queryFn: listMissions,
});
if (missionsQuery.isPending) {
return <Loader />;
}
if (missionsQuery.isError) {
return (
<Alert color="red" title="Could not load missions">
Is the API running?
</Alert>
);
}
return (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{missionsQuery.data.map((mission) => (
<Table.Tr key={mission.id}>
<Table.Td>{mission.name}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{mission.description}
</Text>
</Table.Td>
<Table.Td>
<MissionStatusBadge status={mission.status} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
@@ -0,0 +1,29 @@
import '@testing-library/jest-dom/vitest';
// Mantine relies on browser APIs that jsdom does not implement.
// https://mantine.dev/guides/vitest/
const { getComputedStyle } = window;
window.getComputedStyle = (elt) => getComputedStyle(elt);
window.HTMLElement.prototype.scrollIntoView = () => {};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverMock;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="vitest/config" />
import { env } from 'process';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
const apiBaseAddress = env.ASPNETCORE_URLS
? env.ASPNETCORE_URLS.split(';')[0]
: 'http://localhost:5010';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),
react(),
],
server: {
port: 5173,
proxy: {
'/api': apiBaseAddress,
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});
+22
View File
@@ -0,0 +1,22 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azurerm" {
version = "4.81.0"
constraints = "~> 4.0"
hashes = [
"h1:XhToZua4gtih1Kv8RdStcfND83G4Tmb6GZFT4jEUhDU=",
"zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3",
"zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae",
"zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e",
"zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151",
"zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e",
"zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea",
"zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b",
"zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b",
"zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff",
"zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6",
]
}
+61
View File
@@ -0,0 +1,61 @@
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "mission_control" {
name = "rg-mission-control-${var.environment}"
location = var.location
}
resource "azurerm_service_plan" "mission_control" {
name = "asp-mission-control-${var.environment}"
resource_group_name = azurerm_resource_group.mission_control.name
location = azurerm_resource_group.mission_control.location
os_type = "Linux"
sku_name = "B1"
}
resource "azurerm_linux_web_app" "mission_control" {
name = "app-mission-control-${var.environment}"
resource_group_name = azurerm_resource_group.mission_control.name
location = azurerm_resource_group.mission_control.location
service_plan_id = azurerm_service_plan.mission_control.id
site_config {
application_stack {
dotnet_version = "10.0"
}
}
connection_string {
name = "MissionControl"
type = "SQLAzure"
value = "Server=tcp:${azurerm_mssql_server.mission_control.fully_qualified_domain_name},1433;Database=${azurerm_mssql_database.mission_control.name};User ID=${var.sql_admin_login};Password=${var.sql_admin_password};Encrypt=True;"
}
}
resource "azurerm_mssql_server" "mission_control" {
name = "sql-mission-control-${var.environment}"
resource_group_name = azurerm_resource_group.mission_control.name
location = azurerm_resource_group.mission_control.location
version = "12.0"
administrator_login = var.sql_admin_login
administrator_login_password = var.sql_admin_password
}
resource "azurerm_mssql_database" "mission_control" {
name = "sqldb-mission-control"
server_id = azurerm_mssql_server.mission_control.id
sku_name = "Basic"
}
+4
View File
@@ -0,0 +1,4 @@
output "web_app_url" {
description = "Public URL of the Mission Control web app."
value = "https://${azurerm_linux_web_app.mission_control.default_hostname}"
}
+23
View File
@@ -0,0 +1,23 @@
variable "environment" {
description = "Deployment environment name, used in resource names."
type = string
default = "dev"
}
variable "location" {
description = "Azure region to deploy into."
type = string
default = "uksouth"
}
variable "sql_admin_login" {
description = "Administrator login for the SQL server."
type = string
default = "missioncontrol"
}
variable "sql_admin_password" {
description = "Administrator password for the SQL server."
type = string
sensitive = true
}
@@ -0,0 +1,42 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using MissionControl.Domain.Entities;
using MissionControl.Server.Endpoints.Missions;
using Shouldly;
namespace MissionControl.Server.Tests.Endpoints.Missions;
public class CreateMissionTests
{
[Fact]
public async Task Creates_a_planned_mission_and_saves_it()
{
using var database = new TestDatabase();
var endpoint = Factory.Create<CreateMission>(database.Context);
var request = new CreateMissionRequest
{
Name = "Orbital Bloom",
Description = "Grow the first orchard in orbit.",
};
await endpoint.HandleAsync(request, CancellationToken.None);
var saved = await database.Context.Missions.SingleAsync();
saved.Name.ShouldBe("Orbital Bloom");
saved.Status.ShouldBe(MissionStatus.Planned);
endpoint.Response.Id.ShouldBe(saved.Id);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Rejects_a_blank_mission_name(string name)
{
var validator = new CreateMissionRequestValidator();
var result = validator.Validate(new CreateMissionRequest { Name = name });
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.PropertyName == nameof(CreateMissionRequest.Name));
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MissionControl.Server\MissionControl.Server.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,34 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using MissionControl.Data;
namespace MissionControl.Server.Tests;
/// <summary>
/// An in-memory SQLite database that speaks real EF Core, for fast endpoint tests
/// without a SQL Server instance.
/// </summary>
public sealed class TestDatabase : IDisposable
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");
public TestDatabase()
{
_connection.Open();
var options = new DbContextOptionsBuilder<MissionControlContext>()
.UseSqlite(_connection)
.Options;
Context = new MissionControlContext(options);
Context.Database.EnsureCreated();
}
public MissionControlContext Context { get; }
public void Dispose()
{
Context.Dispose();
_connection.Dispose();
}
}