This commit is contained in:
commit
d7479db902
39
.gitea/workflows/ci.yml
Normal file
39
.gitea/workflows/ci.yml
Normal 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
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
*.suo
|
||||
*.swp
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
appsettings.Development.json
|
||||
|
||||
# Build artifacts
|
||||
artifacts/
|
||||
TestResults/
|
||||
90
Controllers/AuthController.cs
Normal file
90
Controllers/AuthController.cs
Normal 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
56
Controllers/HuntAreasController.cs
Normal file
56
Controllers/HuntAreasController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
139
Controllers/HuntsController.cs
Normal file
139
Controllers/HuntsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
66
Controllers/LocationsController.cs
Normal file
66
Controllers/LocationsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
71
Controllers/MapPointsController.cs
Normal file
71
Controllers/MapPointsController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
62
Controllers/MessagesController.cs
Normal file
62
Controllers/MessagesController.cs
Normal 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
16
HuntingAppApi.csproj
Normal 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
126
Models/Dto.cs
Normal 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
7
NuGet.config
Normal 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
47
Program.cs
Normal 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
34
README.md
Normal 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.
|
||||
36
Services/ExternalTokenValidator.cs
Normal file
36
Services/ExternalTokenValidator.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
31
Services/JwtTokenService.cs
Normal file
31
Services/JwtTokenService.cs
Normal 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
154
Sql/001_schema.sql
Normal 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
10
appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
docs/gitea-actions-setup.md
Normal file
56
docs/gitea-actions-setup.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user