first commit
Some checks failed
Build / Restore, build and publish (push) Failing after 2m7s

This commit is contained in:
Finn Skov Henriksen 2026-08-31 12:29:23 +02:00
commit d7479db902
18 changed files with 1055 additions and 0 deletions

39
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,39 @@
name: Build
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
jobs:
build:
name: Restore, build and publish
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
run: dotnet restore HuntingAppApi.csproj
- name: Build
run: dotnet build HuntingAppApi.csproj --configuration Release --no-restore
- name: Publish
run: dotnet publish HuntingAppApi.csproj --configuration Release --no-build --output ./artifacts/huntapi
- name: Upload published API
uses: actions/upload-artifact@v4
with:
name: huntapi
path: ./artifacts/huntapi

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
bin/
obj/
.vs/
.vscode/
*.user
*.suo
*.swp
# Local environment files
.env
appsettings.Development.json
# Build artifacts
artifacts/
TestResults/

View File

@ -0,0 +1,90 @@
using HuntingAppApi.Models;
using HuntingAppApi.Services;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using NpgsqlTypes;
namespace HuntingAppApi.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController(NpgsqlDataSource dataSource, ExternalTokenValidator validator, JwtTokenService jwt) : ControllerBase
{
[HttpPost("external-login")]
public async Task<ActionResult<LoginResponse>> ExternalLogin([FromBody] ExternalLoginRequest request, CancellationToken ct)
{
var external = await validator.ValidateAsync(request.Provider, request.IdToken, ct);
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
Guid? userId = null;
string displayName = external.DisplayName;
string? email = external.Email;
await using (var find = new NpgsqlCommand("""
SELECT u.id, u.display_name, u.email
FROM user_identity i
JOIN app_user u ON u.id = i.user_id
WHERE i.provider = @provider AND i.provider_subject = @subject
""", conn, tx))
{
find.Parameters.AddWithValue("provider", external.Provider);
find.Parameters.AddWithValue("subject", external.Subject);
await using var r = await find.ExecuteReaderAsync(ct);
if (await r.ReadAsync(ct))
{
userId = r.GetGuid(0);
displayName = r.GetString(1);
email = r.IsDBNull(2) ? null : r.GetString(2);
}
}
if (userId is null)
{
userId = Guid.NewGuid();
await using var createUser = new NpgsqlCommand("""
INSERT INTO app_user (id, display_name, email, avatar_url)
VALUES (@id, @display_name, @email, @avatar_url)
""", conn, tx);
createUser.Parameters.AddWithValue("id", userId.Value);
createUser.Parameters.AddWithValue("display_name", displayName);
createUser.Parameters.AddWithValue("email", (object?)email ?? DBNull.Value);
createUser.Parameters.AddWithValue("avatar_url", (object?)external.AvatarUrl ?? DBNull.Value);
await createUser.ExecuteNonQueryAsync(ct);
await using var createIdentity = new NpgsqlCommand("""
INSERT INTO user_identity (user_id, provider, provider_subject, email, email_verified, display_name, avatar_url)
VALUES (@user_id, @provider, @subject, @email, @email_verified, @display_name, @avatar_url)
""", conn, tx);
createIdentity.Parameters.AddWithValue("user_id", userId.Value);
createIdentity.Parameters.AddWithValue("provider", external.Provider);
createIdentity.Parameters.AddWithValue("subject", external.Subject);
createIdentity.Parameters.AddWithValue("email", (object?)email ?? DBNull.Value);
createIdentity.Parameters.AddWithValue("email_verified", external.EmailVerified);
createIdentity.Parameters.AddWithValue("display_name", displayName);
createIdentity.Parameters.AddWithValue("avatar_url", (object?)external.AvatarUrl ?? DBNull.Value);
await createIdentity.ExecuteNonQueryAsync(ct);
}
else
{
await using var updateLogin = new NpgsqlCommand("""
UPDATE user_identity SET last_login_at = now()
WHERE provider = @provider AND provider_subject = @subject
""", conn, tx);
updateLogin.Parameters.AddWithValue("provider", external.Provider);
updateLogin.Parameters.AddWithValue("subject", external.Subject);
await updateLogin.ExecuteNonQueryAsync(ct);
}
await tx.CommitAsync(ct);
return Ok(new LoginResponse
{
UserId = userId.Value,
DisplayName = displayName,
Email = email,
Token = jwt.CreateToken(userId.Value, displayName, email)
});
}
}

View File

@ -0,0 +1,56 @@
using HuntingAppApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
namespace HuntingAppApi.Controllers;
[ApiController]
[Authorize]
[Route("api/hunt-areas")]
public sealed class HuntAreasController(NpgsqlDataSource dataSource) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<HuntAreaDto>>> GetAll(CancellationToken ct)
{
var result = new List<HuntAreaDto>();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
SELECT id, name, description, CASE WHEN geom IS NULL THEN NULL ELSE ST_AsGeoJSON(geom) END
FROM hunt_area
ORDER BY name
""", conn);
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
{
result.Add(new HuntAreaDto
{
Id = r.GetGuid(0),
Name = r.GetString(1),
Description = r.IsDBNull(2) ? null : r.GetString(2),
GeoJson = r.IsDBNull(3) ? null : r.GetString(3)
});
}
return Ok(result);
}
[HttpPost]
public async Task<ActionResult<ApiId>> Create([FromBody] CreateHuntAreaRequest request, CancellationToken ct)
{
var id = Guid.NewGuid();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
INSERT INTO hunt_area (id, name, description, geom)
VALUES (@id, @name, @description,
CASE WHEN @geojson::text IS NULL OR @geojson::text = '' THEN NULL
ELSE ST_SetSRID(ST_GeomFromGeoJSON(@geojson), 4326)::geometry(MultiPolygon, 4326)
END)
""", conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("name", request.Name);
cmd.Parameters.AddWithValue("description", (object?)request.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("geojson", (object?)request.GeoJson ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
return Ok(new ApiId(id));
}
}

View File

@ -0,0 +1,139 @@
using HuntingAppApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
namespace HuntingAppApi.Controllers;
[ApiController]
[Authorize]
[Route("api/hunts")]
public sealed class HuntsController(NpgsqlDataSource dataSource) : ControllerBase
{
[HttpGet("area/{huntAreaId:guid}")]
public async Task<ActionResult<List<HuntDto>>> GetByArea(Guid huntAreaId, CancellationToken ct)
{
var result = new List<HuntDto>();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
SELECT id, hunt_area_id, title, description, starts_at, ends_at, status
FROM hunt
WHERE hunt_area_id = @hunt_area_id
ORDER BY starts_at DESC NULLS LAST, created_at DESC
""", conn);
cmd.Parameters.AddWithValue("hunt_area_id", huntAreaId);
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
{
result.Add(new HuntDto
{
Id = r.GetGuid(0), HuntAreaId = r.GetGuid(1), Title = r.GetString(2),
Description = r.IsDBNull(3) ? null : r.GetString(3),
StartsAt = r.IsDBNull(4) ? null : r.GetFieldValue<DateTimeOffset>(4),
EndsAt = r.IsDBNull(5) ? null : r.GetFieldValue<DateTimeOffset>(5),
Status = r.GetString(6)
});
}
return Ok(result);
}
[HttpPost]
public async Task<ActionResult<ApiId>> Create([FromBody] CreateHuntRequest request, CancellationToken ct)
{
var id = Guid.NewGuid();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
INSERT INTO hunt (id, hunt_area_id, title, description, starts_at, ends_at)
VALUES (@id, @hunt_area_id, @title, @description, @starts_at, @ends_at)
""", conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("hunt_area_id", request.HuntAreaId);
cmd.Parameters.AddWithValue("title", request.Title);
cmd.Parameters.AddWithValue("description", (object?)request.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("starts_at", (object?)request.StartsAt ?? DBNull.Value);
cmd.Parameters.AddWithValue("ends_at", (object?)request.EndsAt ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
return Ok(new ApiId(id));
}
[HttpPatch("{huntId:guid}/status/{status}")]
public async Task<IActionResult> SetStatus(Guid huntId, string status, CancellationToken ct)
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("UPDATE hunt SET status = @status WHERE id = @id", conn);
cmd.Parameters.AddWithValue("id", huntId);
cmd.Parameters.AddWithValue("status", status);
return await cmd.ExecuteNonQueryAsync(ct) == 1 ? NoContent() : NotFound();
}
[HttpPost("{huntId:guid}/participants")]
public async Task<ActionResult<ApiId>> AddParticipant(Guid huntId, [FromBody] JoinHuntRequest request, CancellationToken ct)
{
var id = Guid.NewGuid();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
INSERT INTO hunt_participant (id, hunt_id, user_id, role, status)
VALUES (@id, @hunt_id, @user_id, @role, 'accepted')
ON CONFLICT (hunt_id, user_id) DO UPDATE SET role = EXCLUDED.role
RETURNING id
""", conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("hunt_id", huntId);
cmd.Parameters.AddWithValue("user_id", request.UserId);
cmd.Parameters.AddWithValue("role", request.Role);
var returned = (Guid)(await cmd.ExecuteScalarAsync(ct))!;
return Ok(new ApiId(returned));
}
[HttpPost("{huntId:guid}/assignments")]
public async Task<ActionResult<ApiId>> Assign(Guid huntId, [FromBody] AssignParticipantRequest request, CancellationToken ct)
{
var id = Guid.NewGuid();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
INSERT INTO hunt_assignment (id, hunt_id, participant_id, map_point_id, note)
VALUES (@id, @hunt_id, @participant_id, @map_point_id, @note)
ON CONFLICT (hunt_id, participant_id)
DO UPDATE SET map_point_id = EXCLUDED.map_point_id, note = EXCLUDED.note, assigned_at = now()
RETURNING id
""", conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("hunt_id", huntId);
cmd.Parameters.AddWithValue("participant_id", request.ParticipantId);
cmd.Parameters.AddWithValue("map_point_id", request.MapPointId);
cmd.Parameters.AddWithValue("note", (object?)request.Note ?? DBNull.Value);
var returned = (Guid)(await cmd.ExecuteScalarAsync(ct))!;
return Ok(new ApiId(returned));
}
[HttpGet("{huntId:guid}/participant-locations")]
public async Task<ActionResult<List<ParticipantLocationDto>>> GetParticipantLocations(Guid huntId, CancellationToken ct)
{
var result = new List<ParticipantLocationDto>();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
SELECT hp.id, u.id, u.display_name, u.color,
ST_Y(l.geom), ST_X(l.geom), l.accuracy_m, l.battery_percent, l.recorded_at
FROM hunt_participant hp
JOIN app_user u ON u.id = hp.user_id
LEFT JOIN user_current_location l ON l.participant_id = hp.id
WHERE hp.hunt_id = @hunt_id
ORDER BY u.display_name
""", conn);
cmd.Parameters.AddWithValue("hunt_id", huntId);
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
{
result.Add(new ParticipantLocationDto
{
ParticipantId = r.GetGuid(0), UserId = r.GetGuid(1), DisplayName = r.GetString(2),
Color = r.IsDBNull(3) ? null : r.GetString(3),
Latitude = r.IsDBNull(4) ? null : r.GetDouble(4), Longitude = r.IsDBNull(5) ? null : r.GetDouble(5),
AccuracyM = r.IsDBNull(6) ? null : r.GetDecimal(6),
BatteryPercent = r.IsDBNull(7) ? null : r.GetInt32(7),
RecordedAt = r.IsDBNull(8) ? null : r.GetFieldValue<DateTimeOffset>(8)
});
}
return Ok(result);
}
}

View File

@ -0,0 +1,66 @@
using HuntingAppApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
namespace HuntingAppApi.Controllers;
[ApiController]
[Authorize]
[Route("api/locations")]
public sealed class LocationsController(NpgsqlDataSource dataSource) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Upsert([FromBody] UpsertLocationRequest request, CancellationToken ct)
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
await using (var current = new NpgsqlCommand("""
INSERT INTO user_current_location
(participant_id, geom, accuracy_m, speed_mps, heading_degrees, battery_percent, recorded_at)
VALUES
(@participant_id, ST_SetSRID(ST_MakePoint(@longitude, @latitude), 4326), @accuracy_m,
@speed_mps, @heading_degrees, @battery_percent, @recorded_at)
ON CONFLICT (participant_id) DO UPDATE SET
geom = EXCLUDED.geom,
accuracy_m = EXCLUDED.accuracy_m,
speed_mps = EXCLUDED.speed_mps,
heading_degrees = EXCLUDED.heading_degrees,
battery_percent = EXCLUDED.battery_percent,
recorded_at = EXCLUDED.recorded_at,
received_at = now()
""", conn, tx))
{
AddLocationParams(current, request);
await current.ExecuteNonQueryAsync(ct);
}
await using (var history = new NpgsqlCommand("""
INSERT INTO user_location_history
(participant_id, geom, accuracy_m, speed_mps, heading_degrees, battery_percent, recorded_at)
VALUES
(@participant_id, ST_SetSRID(ST_MakePoint(@longitude, @latitude), 4326), @accuracy_m,
@speed_mps, @heading_degrees, @battery_percent, @recorded_at)
""", conn, tx))
{
AddLocationParams(history, request);
await history.ExecuteNonQueryAsync(ct);
}
await tx.CommitAsync(ct);
return NoContent();
}
private static void AddLocationParams(NpgsqlCommand cmd, UpsertLocationRequest request)
{
cmd.Parameters.AddWithValue("participant_id", request.ParticipantId);
cmd.Parameters.AddWithValue("longitude", request.Longitude);
cmd.Parameters.AddWithValue("latitude", request.Latitude);
cmd.Parameters.AddWithValue("accuracy_m", (object?)request.AccuracyM ?? DBNull.Value);
cmd.Parameters.AddWithValue("speed_mps", (object?)request.SpeedMps ?? DBNull.Value);
cmd.Parameters.AddWithValue("heading_degrees", (object?)request.HeadingDegrees ?? DBNull.Value);
cmd.Parameters.AddWithValue("battery_percent", (object?)request.BatteryPercent ?? DBNull.Value);
cmd.Parameters.AddWithValue("recorded_at", request.RecordedAt);
}
}

View File

@ -0,0 +1,71 @@
using HuntingAppApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
namespace HuntingAppApi.Controllers;
[ApiController]
[Authorize]
[Route("api/map-points")]
public sealed class MapPointsController(NpgsqlDataSource dataSource) : ControllerBase
{
[HttpGet("area/{huntAreaId:guid}")]
public async Task<ActionResult<List<MapPointDto>>> GetByArea(Guid huntAreaId, CancellationToken ct)
{
var result = new List<MapPointDto>();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
SELECT mp.id, mp.hunt_area_id, t.name, t.icon, t.color, mp.name, mp.description,
ST_Y(mp.geom), ST_X(mp.geom), mp.image_url
FROM map_point mp
JOIN map_point_type t ON t.id = mp.type_id
WHERE mp.hunt_area_id = @hunt_area_id AND mp.is_active = true
ORDER BY t.name, mp.name
""", conn);
cmd.Parameters.AddWithValue("hunt_area_id", huntAreaId);
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
{
result.Add(new MapPointDto
{
Id = r.GetGuid(0),
HuntAreaId = r.GetGuid(1),
Type = r.GetString(2),
Icon = r.IsDBNull(3) ? null : r.GetString(3),
Color = r.IsDBNull(4) ? null : r.GetString(4),
Name = r.GetString(5),
Description = r.IsDBNull(6) ? null : r.GetString(6),
Latitude = r.GetDouble(7),
Longitude = r.GetDouble(8),
ImageUrl = r.IsDBNull(9) ? null : r.GetString(9)
});
}
return Ok(result);
}
[HttpPost]
public async Task<ActionResult<ApiId>> Create([FromBody] CreateMapPointRequest request, CancellationToken ct)
{
var id = Guid.NewGuid();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
INSERT INTO map_point (id, hunt_area_id, type_id, name, description, geom, image_url)
SELECT @id, @hunt_area_id, t.id, @name, @description,
ST_SetSRID(ST_MakePoint(@longitude, @latitude), 4326), @image_url
FROM map_point_type t
WHERE t.name = @type
""", conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("hunt_area_id", request.HuntAreaId);
cmd.Parameters.AddWithValue("type", request.Type);
cmd.Parameters.AddWithValue("name", request.Name);
cmd.Parameters.AddWithValue("description", (object?)request.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("longitude", request.Longitude);
cmd.Parameters.AddWithValue("latitude", request.Latitude);
cmd.Parameters.AddWithValue("image_url", (object?)request.ImageUrl ?? DBNull.Value);
var rows = await cmd.ExecuteNonQueryAsync(ct);
if (rows == 0) return BadRequest($"Unknown map point type: {request.Type}");
return Ok(new ApiId(id));
}
}

View File

@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Npgsql;
namespace HuntingAppApi.Controllers;
public sealed class CreateMessageRequest
{
public Guid? SenderParticipantId { get; set; }
public string Message { get; set; } = "";
}
[ApiController]
[Authorize]
[Route("api/hunts/{huntId:guid}/messages")]
public sealed class MessagesController(NpgsqlDataSource dataSource) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(Guid huntId, [FromBody] CreateMessageRequest request, CancellationToken ct)
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
INSERT INTO hunt_message (hunt_id, sender_participant_id, message)
VALUES (@hunt_id, @sender_participant_id, @message)
""", conn);
cmd.Parameters.AddWithValue("hunt_id", huntId);
cmd.Parameters.AddWithValue("sender_participant_id", (object?)request.SenderParticipantId ?? DBNull.Value);
cmd.Parameters.AddWithValue("message", request.Message);
await cmd.ExecuteNonQueryAsync(ct);
return NoContent();
}
[HttpGet]
public async Task<IActionResult> Get(Guid huntId, CancellationToken ct)
{
var list = new List<object>();
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand("""
SELECT m.id, m.sender_participant_id, u.display_name, m.message, m.created_at
FROM hunt_message m
LEFT JOIN hunt_participant hp ON hp.id = m.sender_participant_id
LEFT JOIN app_user u ON u.id = hp.user_id
WHERE m.hunt_id = @hunt_id
ORDER BY m.created_at DESC
LIMIT 100
""", conn);
cmd.Parameters.AddWithValue("hunt_id", huntId);
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
{
list.Add(new
{
id = r.GetGuid(0),
senderParticipantId = r.IsDBNull(1) ? (Guid?)null : r.GetGuid(1),
senderName = r.IsDBNull(2) ? null : r.GetString(2),
message = r.GetString(3),
createdAt = r.GetFieldValue<DateTimeOffset>(4)
});
}
return Ok(list);
}
}

16
HuntingAppApi.csproj Normal file
View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Npgsql.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup>
</Project>

126
Models/Dto.cs Normal file
View File

@ -0,0 +1,126 @@
namespace HuntingAppApi.Models;
public sealed record PointDto(double Latitude, double Longitude);
public sealed record ApiId(Guid Id);
public sealed class HuntAreaDto
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public string? GeoJson { get; set; }
}
public sealed class CreateHuntAreaRequest
{
public string Name { get; set; } = "";
public string? Description { get; set; }
public string? GeoJson { get; set; }
}
public sealed class CreateMapPointRequest
{
public Guid HuntAreaId { get; set; }
public string Type { get; set; } = "HuntingTower";
public string Name { get; set; } = "";
public string? Description { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public string? ImageUrl { get; set; }
}
public sealed class MapPointDto
{
public Guid Id { get; set; }
public Guid HuntAreaId { get; set; }
public string Type { get; set; } = "";
public string? Icon { get; set; }
public string? Color { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public string? ImageUrl { get; set; }
}
public sealed class CreateHuntRequest
{
public Guid HuntAreaId { get; set; }
public string Title { get; set; } = "";
public string? Description { get; set; }
public DateTimeOffset? StartsAt { get; set; }
public DateTimeOffset? EndsAt { get; set; }
}
public sealed class HuntDto
{
public Guid Id { get; set; }
public Guid HuntAreaId { get; set; }
public string Title { get; set; } = "";
public string? Description { get; set; }
public DateTimeOffset? StartsAt { get; set; }
public DateTimeOffset? EndsAt { get; set; }
public string Status { get; set; } = "";
}
public sealed class JoinHuntRequest
{
public Guid UserId { get; set; }
public string Role { get; set; } = "hunter";
}
public sealed class AssignParticipantRequest
{
public Guid ParticipantId { get; set; }
public Guid MapPointId { get; set; }
public string? Note { get; set; }
}
public sealed class ParticipantLocationDto
{
public Guid ParticipantId { get; set; }
public Guid UserId { get; set; }
public string DisplayName { get; set; } = "";
public string? Color { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public decimal? AccuracyM { get; set; }
public int? BatteryPercent { get; set; }
public DateTimeOffset? RecordedAt { get; set; }
}
public sealed class UpsertLocationRequest
{
public Guid ParticipantId { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public decimal? AccuracyM { get; set; }
public decimal? SpeedMps { get; set; }
public decimal? HeadingDegrees { get; set; }
public int? BatteryPercent { get; set; }
public DateTimeOffset RecordedAt { get; set; }
}
public sealed class ExternalLoginRequest
{
public string Provider { get; set; } = ""; // google or microsoft
public string IdToken { get; set; } = "";
}
public sealed class LoginResponse
{
public Guid UserId { get; set; }
public string DisplayName { get; set; } = "";
public string? Email { get; set; }
public string Token { get; set; } = "";
}
public sealed class ExternalUserInfo
{
public string Provider { get; set; } = "";
public string Subject { get; set; } = "";
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public string DisplayName { get; set; } = "";
public string? AvatarUrl { get; set; }
}

7
NuGet.config Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

47
Program.cs Normal file
View File

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Serialization;
using Npgsql;
using System.Text;
using HuntingAppApi.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
.AddNewtonsoftJson(o =>
{
o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
o.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
builder.Services.AddNpgsqlDataSource(builder.Configuration.GetConnectionString("Default")!);
builder.Services.AddScoped<JwtTokenService>();
builder.Services.AddHttpClient<ExternalTokenValidator>();
var jwtSecret = builder.Configuration["Authentication:JwtSecret"]!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Authentication:Issuer"],
ValidAudience = builder.Configuration["Authentication:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret))
};
});
builder.Services.AddAuthorization();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

34
README.md Normal file
View File

@ -0,0 +1,34 @@
# HuntingAppApi
ASP.NET Core / .NET 10 Web API sample using Controllers, Newtonsoft.Json, Npgsql and PostGIS.
## Setup
```bash
createdb hunting_app
psql -d hunting_app -f Sql/001_schema.sql
dotnet restore
dotnet run
```
Update `appsettings.json` with your PostgreSQL connection string and JWT secret.
## Main endpoints
- `POST /api/auth/external-login`
- `GET /api/hunt-areas`
- `POST /api/hunt-areas`
- `GET /api/map-points/area/{huntAreaId}`
- `POST /api/map-points`
- `GET /api/hunts/area/{huntAreaId}`
- `POST /api/hunts`
- `POST /api/hunts/{huntId}/participants`
- `POST /api/hunts/{huntId}/assignments`
- `GET /api/hunts/{huntId}/participant-locations`
- `POST /api/locations`
- `GET /api/hunts/{huntId}/messages`
- `POST /api/hunts/{huntId}/messages`
## Important production notes
The external token validator is intentionally simple. Before production, validate Google/Microsoft ID tokens against issuer, audience/client ID, expiry and JWKS signature validation.

View File

@ -0,0 +1,36 @@
using HuntingAppApi.Models;
using Newtonsoft.Json.Linq;
namespace HuntingAppApi.Services;
public sealed class ExternalTokenValidator(HttpClient httpClient)
{
public async Task<ExternalUserInfo> ValidateAsync(string provider, string idToken, CancellationToken ct)
{
provider = provider.ToLowerInvariant();
// Production note: This is intentionally simple. For stricter production validation,
// validate issuer, audience/client_id and signature using OIDC metadata/JWKS.
var url = provider switch
{
"google" => $"https://oauth2.googleapis.com/tokeninfo?id_token={Uri.EscapeDataString(idToken)}",
"microsoft" => $"https://login.microsoftonline.com/common/oauth2/v2.0/tokeninfo?id_token={Uri.EscapeDataString(idToken)}",
_ => throw new ArgumentException("Unsupported provider")
};
var json = await httpClient.GetStringAsync(url, ct);
var obj = JObject.Parse(json);
var sub = obj.Value<string>("sub") ?? throw new InvalidOperationException("Token has no sub claim");
var name = obj.Value<string>("name") ?? obj.Value<string>("preferred_username") ?? obj.Value<string>("email") ?? "User";
return new ExternalUserInfo
{
Provider = provider,
Subject = sub,
Email = obj.Value<string>("email") ?? obj.Value<string>("preferred_username"),
EmailVerified = obj.Value<string>("email_verified") == "true" || obj.Value<bool?>("email_verified") == true,
DisplayName = name,
AvatarUrl = obj.Value<string>("picture")
};
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace HuntingAppApi.Services;
public sealed class JwtTokenService(IConfiguration configuration)
{
public string CreateToken(Guid userId, string displayName, string? email)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new("display_name", displayName)
};
if (!string.IsNullOrWhiteSpace(email)) claims.Add(new Claim(JwtRegisteredClaimNames.Email, email));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Authentication:JwtSecret"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: configuration["Authentication:Issuer"],
audience: configuration["Authentication:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddDays(14),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

154
Sql/001_schema.sql Normal file
View File

@ -0,0 +1,154 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS hunt_area (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
geom geometry(MultiPolygon, 4326),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS app_user (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
display_name text NOT NULL,
email text,
avatar_url text,
color text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
CREATE TABLE IF NOT EXISTS user_identity (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
provider text NOT NULL,
provider_subject text NOT NULL,
email text,
email_verified boolean,
display_name text,
avatar_url text,
first_login_at timestamptz NOT NULL DEFAULT now(),
last_login_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, provider_subject),
CHECK (provider IN ('google', 'microsoft', 'apple'))
);
CREATE TABLE IF NOT EXISTS hunt (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_area_id uuid NOT NULL REFERENCES hunt_area(id),
title text NOT NULL,
description text,
starts_at timestamptz,
ends_at timestamptz,
status text NOT NULL DEFAULT 'planned',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS map_point_type (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
icon text,
color text
);
INSERT INTO map_point_type (name, icon, color) VALUES
('HuntingTower', 'tower', '#F59E0B'),
('FeedingPlace', 'corn', '#84CC16'),
('Parking', 'parking', '#3B82F6'),
('MeetingPoint', 'flag', '#22C55E'),
('Observation', 'eye', '#A855F7')
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS map_point (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_area_id uuid NOT NULL REFERENCES hunt_area(id),
type_id uuid NOT NULL REFERENCES map_point_type(id),
name text NOT NULL,
description text,
geom geometry(Point, 4326) NOT NULL,
image_url text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS map_zone (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_area_id uuid NOT NULL REFERENCES hunt_area(id),
name text NOT NULL,
zone_type text NOT NULL,
description text,
geom geometry(MultiPolygon, 4326) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS hunt_area_member (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_area_id uuid NOT NULL REFERENCES hunt_area(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (hunt_area_id, user_id)
);
CREATE TABLE IF NOT EXISTS hunt_participant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_id uuid NOT NULL REFERENCES hunt(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_user(id),
role text NOT NULL DEFAULT 'hunter',
status text NOT NULL DEFAULT 'invited',
share_location boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (hunt_id, user_id)
);
CREATE TABLE IF NOT EXISTS hunt_assignment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_id uuid NOT NULL REFERENCES hunt(id) ON DELETE CASCADE,
participant_id uuid NOT NULL REFERENCES hunt_participant(id) ON DELETE CASCADE,
map_point_id uuid NOT NULL REFERENCES map_point(id),
note text,
assigned_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (hunt_id, participant_id)
);
CREATE TABLE IF NOT EXISTS user_current_location (
participant_id uuid PRIMARY KEY REFERENCES hunt_participant(id) ON DELETE CASCADE,
geom geometry(Point, 4326) NOT NULL,
accuracy_m numeric(8,2),
speed_mps numeric(8,2),
heading_degrees numeric(6,2),
battery_percent int,
recorded_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_location_history (
id bigserial PRIMARY KEY,
participant_id uuid NOT NULL REFERENCES hunt_participant(id) ON DELETE CASCADE,
geom geometry(Point, 4326) NOT NULL,
accuracy_m numeric(8,2),
speed_mps numeric(8,2),
heading_degrees numeric(6,2),
battery_percent int,
recorded_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS hunt_message (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
hunt_id uuid NOT NULL REFERENCES hunt(id) ON DELETE CASCADE,
sender_participant_id uuid REFERENCES hunt_participant(id),
message text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_hunt_area_geom ON hunt_area USING gist (geom);
CREATE INDEX IF NOT EXISTS idx_map_point_geom ON map_point USING gist (geom);
CREATE INDEX IF NOT EXISTS idx_map_zone_geom ON map_zone USING gist (geom);
CREATE INDEX IF NOT EXISTS idx_user_current_location_geom ON user_current_location USING gist (geom);
CREATE INDEX IF NOT EXISTS idx_user_location_history_geom ON user_location_history USING gist (geom);
CREATE INDEX IF NOT EXISTS idx_user_location_history_participant_time ON user_location_history (participant_id, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_hunt_participant_hunt ON hunt_participant (hunt_id);
CREATE INDEX IF NOT EXISTS idx_hunt_assignment_hunt ON hunt_assignment (hunt_id);

10
appsettings.json Normal file
View File

@ -0,0 +1,10 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=hunting_app;Username=postgres;Password=postgres;Pooling=true;Maximum Pool Size=50;"
},
"Authentication": {
"JwtSecret": "CHANGE_THIS_TO_A_LONG_RANDOM_SECRET_MIN_32_CHARS",
"Issuer": "hunting-app-api",
"Audience": "hunting-app"
}
}

View File

@ -0,0 +1,56 @@
# Gitea Actions setup
This project builds automatically with Gitea Actions when changes are pushed to
the `master` branch. The workflow is defined in `.gitea/workflows/ci.yml`.
## One-time Gitea server setup
1. Make sure the Gitea server is version 1.19.0 or newer.
2. If the server is older than 1.21.0, enable Actions in `app.ini`:
```ini
[actions]
ENABLED = true
```
3. Restart Gitea after changing `app.ini`.
4. In the repository settings, enable repository Actions.
5. Register at least one Gitea runner for the instance, organization, or repository.
## Runner setup
Install Docker on the runner host, then register a runner with a token from:
- Instance runner: `http://git.fshcomputer.dk:3000/-/admin/actions/runners`
- Repository runner: `http://git.fshcomputer.dk:3000/<owner>/<repo>/settings/actions/runners`
Example:
```bash
./act_runner register --no-interactive \
--instance http://git.fshcomputer.dk:3000 \
--token <runner-token>
./act_runner daemon
```
The workflow uses the `ubuntu-latest` runner label and installs .NET 10 with
`actions/setup-dotnet@v4`. The repository also contains a `NuGet.config` file so
the build uses `nuget.org` instead of machine-specific package feeds.
The runner must be able to reach `github.com`/`gitea.com` for actions and
`api.nuget.org` for packages unless you configure a local action/package mirror.
## First push
The local repository currently needs its first commit and a `master` branch for
this workflow to trigger:
```bash
git branch -M master
git add .
git commit -m "Add Gitea CI workflow"
git push -u origin master
```
After the push, open the repository's Actions tab in Gitea.