diff --git a/backend/Application/Abstractions/IMigrationRunner.cs b/backend/Application/Abstractions/IMigrationRunner.cs
new file mode 100644
index 0000000..513c627
--- /dev/null
+++ b/backend/Application/Abstractions/IMigrationRunner.cs
@@ -0,0 +1,6 @@
+namespace TarninaHub.Application.Abstractions;
+
+public interface IMigrationRunner
+{
+ Task ApplyAsync(CancellationToken cancellationToken);
+}
diff --git a/backend/Services/IStorageService.cs b/backend/Application/Abstractions/IStorageService.cs
similarity index 77%
rename from backend/Services/IStorageService.cs
rename to backend/Application/Abstractions/IStorageService.cs
index cf170f7..c5a0add 100644
--- a/backend/Services/IStorageService.cs
+++ b/backend/Application/Abstractions/IStorageService.cs
@@ -1,6 +1,6 @@
-using TarninaHub.Models;
+using TarninaHub.Application.Dtos;
-namespace TarninaHub.Services;
+namespace TarninaHub.Application.Abstractions;
public interface IStorageService
{
diff --git a/backend/Models/StorageModels.cs b/backend/Application/Dtos/StorageModels.cs
similarity index 92%
rename from backend/Models/StorageModels.cs
rename to backend/Application/Dtos/StorageModels.cs
index 00ceb4b..9e1e635 100644
--- a/backend/Models/StorageModels.cs
+++ b/backend/Application/Dtos/StorageModels.cs
@@ -1,4 +1,4 @@
-namespace TarninaHub.Models;
+namespace TarninaHub.Application.Dtos;
public sealed class StorageProbe
{
diff --git a/backend/Application/TarninaHub.Application.csproj b/backend/Application/TarninaHub.Application.csproj
new file mode 100644
index 0000000..60453b2
--- /dev/null
+++ b/backend/Application/TarninaHub.Application.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/backend/Configuration/ConfigureSwaggerOptions.cs b/backend/Configuration/ConfigureSwaggerOptions.cs
new file mode 100644
index 0000000..76aa4e9
--- /dev/null
+++ b/backend/Configuration/ConfigureSwaggerOptions.cs
@@ -0,0 +1,29 @@
+using Asp.Versioning.ApiExplorer;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace TarninaHub.Configuration;
+
+public sealed class ConfigureSwaggerOptions : IConfigureOptions
+{
+ private readonly IApiVersionDescriptionProvider _provider;
+
+ public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
+ {
+ _provider = provider;
+ }
+
+ public void Configure(SwaggerGenOptions options)
+ {
+ foreach (var description in _provider.ApiVersionDescriptions)
+ {
+ options.SwaggerDoc(description.GroupName, new OpenApiInfo
+ {
+ Title = "TarninaHub API",
+ Version = description.ApiVersion.ToString(),
+ Description = "Backend API (BFF) for TarninaHub — Małopolski Hub Innowacji Społecznych.",
+ });
+ }
+ }
+}
diff --git a/backend/Controllers/SystemController.cs b/backend/Controllers/SystemController.cs
index 9d052a4..e33e53b 100644
--- a/backend/Controllers/SystemController.cs
+++ b/backend/Controllers/SystemController.cs
@@ -1,10 +1,12 @@
+using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
-using TarninaHub.Models;
-using TarninaHub.Services;
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
namespace TarninaHub.Controllers;
[ApiController]
+[ApiVersion("1.0")]
[Route("api/system")]
public class SystemController : ControllerBase
{
diff --git a/backend/Domain/Enums/ApplicationStatus.cs b/backend/Domain/Enums/ApplicationStatus.cs
new file mode 100644
index 0000000..36a1f83
--- /dev/null
+++ b/backend/Domain/Enums/ApplicationStatus.cs
@@ -0,0 +1,9 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum ApplicationStatus
+{
+ Draft,
+ Submitted,
+ Accepted,
+ Rejected,
+}
diff --git a/backend/Domain/Enums/DocumentScope.cs b/backend/Domain/Enums/DocumentScope.cs
new file mode 100644
index 0000000..8499103
--- /dev/null
+++ b/backend/Domain/Enums/DocumentScope.cs
@@ -0,0 +1,9 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum DocumentScope
+{
+ Local,
+ Partial,
+ Full,
+ Anonymized,
+}
diff --git a/backend/Domain/Enums/GrantCallStatus.cs b/backend/Domain/Enums/GrantCallStatus.cs
new file mode 100644
index 0000000..b25dda1
--- /dev/null
+++ b/backend/Domain/Enums/GrantCallStatus.cs
@@ -0,0 +1,8 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum GrantCallStatus
+{
+ Planned,
+ Open,
+ Closed,
+}
diff --git a/backend/Domain/Enums/IdeaStatus.cs b/backend/Domain/Enums/IdeaStatus.cs
new file mode 100644
index 0000000..fba8b0c
--- /dev/null
+++ b/backend/Domain/Enums/IdeaStatus.cs
@@ -0,0 +1,10 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum IdeaStatus
+{
+ Draft,
+ Submitted,
+ InReview,
+ Accepted,
+ Rejected,
+}
diff --git a/backend/Domain/Enums/InnovationStage.cs b/backend/Domain/Enums/InnovationStage.cs
new file mode 100644
index 0000000..a7b2440
--- /dev/null
+++ b/backend/Domain/Enums/InnovationStage.cs
@@ -0,0 +1,9 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum InnovationStage
+{
+ Idea,
+ Pilot,
+ Scaling,
+ Service,
+}
diff --git a/backend/Domain/Enums/LocationKind.cs b/backend/Domain/Enums/LocationKind.cs
new file mode 100644
index 0000000..02ef325
--- /dev/null
+++ b/backend/Domain/Enums/LocationKind.cs
@@ -0,0 +1,8 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum LocationKind
+{
+ Gmina,
+ Powiat,
+ Wojewodztwo,
+}
diff --git a/backend/Domain/Enums/PartnershipStatus.cs b/backend/Domain/Enums/PartnershipStatus.cs
new file mode 100644
index 0000000..acbe16e
--- /dev/null
+++ b/backend/Domain/Enums/PartnershipStatus.cs
@@ -0,0 +1,8 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum PartnershipStatus
+{
+ Proposed,
+ Active,
+ Closed,
+}
diff --git a/backend/Domain/Enums/SubmissionStatus.cs b/backend/Domain/Enums/SubmissionStatus.cs
new file mode 100644
index 0000000..1845f5c
--- /dev/null
+++ b/backend/Domain/Enums/SubmissionStatus.cs
@@ -0,0 +1,11 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum SubmissionStatus
+{
+ New,
+ Triaged,
+ Matched,
+ InProgress,
+ Closed,
+ Rejected,
+}
diff --git a/backend/Domain/Enums/TagGroup.cs b/backend/Domain/Enums/TagGroup.cs
new file mode 100644
index 0000000..90f379e
--- /dev/null
+++ b/backend/Domain/Enums/TagGroup.cs
@@ -0,0 +1,10 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum TagGroup
+{
+ Audience,
+ Topic,
+ Stage,
+ Sector,
+ Cross,
+}
diff --git a/backend/Domain/Enums/UserRole.cs b/backend/Domain/Enums/UserRole.cs
new file mode 100644
index 0000000..f96516c
--- /dev/null
+++ b/backend/Domain/Enums/UserRole.cs
@@ -0,0 +1,10 @@
+namespace TarninaHub.Domain.Enums;
+
+public enum UserRole
+{
+ User,
+ Jst,
+ Operator,
+ Manager,
+ Admin,
+}
diff --git a/backend/Domain/TarninaHub.Domain.csproj b/backend/Domain/TarninaHub.Domain.csproj
new file mode 100644
index 0000000..9ed914b
--- /dev/null
+++ b/backend/Domain/TarninaHub.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/backend/Errors/GlobalExceptionHandler.cs b/backend/Errors/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..40cc0b7
--- /dev/null
+++ b/backend/Errors/GlobalExceptionHandler.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+
+namespace TarninaHub.Errors;
+
+public sealed class GlobalExceptionHandler : IExceptionHandler
+{
+ private readonly ILogger _logger;
+
+ public GlobalExceptionHandler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogError(exception, "Unhandled exception for {Method} {Path}",
+ httpContext.Request.Method,
+ httpContext.Request.Path);
+
+ var problem = new ProblemDetails
+ {
+ Type = "https://httpstatuses.io/500",
+ Title = "An unexpected error occurred.",
+ Status = StatusCodes.Status500InternalServerError,
+ Instance = httpContext.Request.Path,
+ };
+
+ httpContext.Response.StatusCode = problem.Status.Value;
+
+ await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
+
+ return true;
+ }
+}
diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..51897e6
--- /dev/null
+++ b/backend/Infrastructure/DependencyInjection.cs
@@ -0,0 +1,52 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using SurrealDb.Net;
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Infrastructure.Persistence;
+using TarninaHub.Infrastructure.Settings;
+
+namespace TarninaHub.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("SurrealDb"));
+
+ var settings = configuration.GetSection("SurrealDb").Get()
+ ?? new SurrealDbSettings();
+
+ var options = new SurrealDbOptions
+ {
+ Endpoint = SurrealDbEndpoint.Build(settings),
+ Namespace = settings.Namespace,
+ Database = settings.Database,
+ };
+
+ if (string.IsNullOrWhiteSpace(settings.RemoteUrl))
+ {
+ // Embedded RocksDB, persisted next to the running app (./data), never in memory.
+ var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
+ Directory.CreateDirectory(dataDirectory);
+
+ services
+ .AddSurreal(options, ServiceLifetime.Singleton)
+ .AddRocksDbProvider();
+ }
+ else
+ {
+ // Remote SurrealDB server on its own dedicated port (used for sync with the frontend).
+ options.Username = settings.Username;
+ options.Password = settings.Password;
+
+ services.AddSurreal(options, ServiceLifetime.Singleton);
+ }
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
+ }
+}
diff --git a/backend/Infrastructure/Persistence/MigrationRunner.cs b/backend/Infrastructure/Persistence/MigrationRunner.cs
new file mode 100644
index 0000000..47b7062
--- /dev/null
+++ b/backend/Infrastructure/Persistence/MigrationRunner.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SurrealDb.Net;
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Infrastructure.Settings;
+
+namespace TarninaHub.Infrastructure.Persistence;
+
+public sealed class MigrationRunner : IMigrationRunner
+{
+ private readonly ISurrealDbClient _client;
+ private readonly SurrealDbSettings _settings;
+ private readonly ILogger _logger;
+
+ public MigrationRunner(
+ ISurrealDbClient client,
+ IOptions settings,
+ ILogger logger)
+ {
+ _client = client;
+ _settings = settings.Value;
+ _logger = logger;
+ }
+
+ public async Task ApplyAsync(CancellationToken cancellationToken)
+ {
+ await EnsureNamespaceAsync(cancellationToken);
+
+ var migrationsPath = Path.Combine(AppContext.BaseDirectory, _settings.MigrationsDirectory);
+
+ if (!Directory.Exists(migrationsPath))
+ {
+ _logger.LogWarning(
+ "Migrations directory '{MigrationsPath}' was not found; skipping migrations.",
+ migrationsPath);
+
+ return;
+ }
+
+ var files = Directory.GetFiles(migrationsPath, "*.surql")
+ .OrderBy(file => file, StringComparer.Ordinal);
+
+ foreach (var file in files)
+ {
+ var sql = await File.ReadAllTextAsync(file, cancellationToken);
+
+ await _client.RawQuery(sql, cancellationToken: cancellationToken);
+
+ _logger.LogInformation("Applied migration {Migration}.", Path.GetFileName(file));
+ }
+ }
+
+ private async Task EnsureNamespaceAsync(CancellationToken cancellationToken)
+ {
+ var sql =
+ $"DEFINE NAMESPACE IF NOT EXISTS {_settings.Namespace}; " +
+ $"USE NS {_settings.Namespace}; " +
+ $"DEFINE DATABASE IF NOT EXISTS {_settings.Database}; " +
+ $"USE DB {_settings.Database};";
+
+ await _client.RawQuery(sql, cancellationToken: cancellationToken);
+ }
+}
diff --git a/backend/Services/StorageService.cs b/backend/Infrastructure/Persistence/StorageService.cs
similarity index 90%
rename from backend/Services/StorageService.cs
rename to backend/Infrastructure/Persistence/StorageService.cs
index 40c6cfc..bf74816 100644
--- a/backend/Services/StorageService.cs
+++ b/backend/Infrastructure/Persistence/StorageService.cs
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Options;
using SurrealDb.Net;
-using TarninaHub.Models;
-using TarninaHub.Settings;
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+using TarninaHub.Infrastructure.Settings;
-namespace TarninaHub.Services;
+namespace TarninaHub.Infrastructure.Persistence;
public sealed class StorageService : IStorageService
{
diff --git a/backend/Infrastructure/Settings/SurrealDbEndpoint.cs b/backend/Infrastructure/Settings/SurrealDbEndpoint.cs
new file mode 100644
index 0000000..40db458
--- /dev/null
+++ b/backend/Infrastructure/Settings/SurrealDbEndpoint.cs
@@ -0,0 +1,21 @@
+namespace TarninaHub.Infrastructure.Settings;
+
+public static class SurrealDbEndpoint
+{
+ ///
+ /// Builds the SurrealDB endpoint. Remote mode uses the configured URL;
+ /// embedded mode uses the RocksDB provider persisted to the data directory
+ /// (forward slashes, so the same endpoint works on Windows and Linux).
+ ///
+ public static string Build(SurrealDbSettings settings)
+ {
+ if (!string.IsNullOrWhiteSpace(settings.RemoteUrl))
+ {
+ return settings.RemoteUrl;
+ }
+
+ var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
+
+ return $"rocksdb://{dataDirectory.Replace('\\', '/')}";
+ }
+}
diff --git a/backend/Settings/SurrealDbSettings.cs b/backend/Infrastructure/Settings/SurrealDbSettings.cs
similarity index 65%
rename from backend/Settings/SurrealDbSettings.cs
rename to backend/Infrastructure/Settings/SurrealDbSettings.cs
index 2295891..bdbc1b1 100644
--- a/backend/Settings/SurrealDbSettings.cs
+++ b/backend/Infrastructure/Settings/SurrealDbSettings.cs
@@ -1,4 +1,4 @@
-namespace TarninaHub.Settings;
+namespace TarninaHub.Infrastructure.Settings;
public sealed class SurrealDbSettings
{
@@ -8,6 +8,12 @@ public sealed class SurrealDbSettings
public string DataDirectory { get; set; } = "data";
+ ///
+ /// Directory (relative to the app base directory) with ordered *.surql
+ /// migration files applied at startup. Copied from backend/migrations.
+ ///
+ public string MigrationsDirectory { get; set; } = "migrations";
+
///
/// When set (e.g. ws://127.0.0.1:8010/rpc), the backend connects to a
/// dedicated SurrealDB server instead of the embedded RocksDB engine.
diff --git a/backend/Infrastructure/TarninaHub.Infrastructure.csproj b/backend/Infrastructure/TarninaHub.Infrastructure.csproj
new file mode 100644
index 0000000..ee76d1b
--- /dev/null
+++ b/backend/Infrastructure/TarninaHub.Infrastructure.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Program.cs b/backend/Program.cs
index 1206fc1..3793e3b 100644
--- a/backend/Program.cs
+++ b/backend/Program.cs
@@ -1,61 +1,74 @@
-using TarninaHub.Services;
-using TarninaHub.Settings;
+using Asp.Versioning;
+using Asp.Versioning.ApiExplorer;
+using Microsoft.Extensions.Options;
+using Swashbuckle.AspNetCore.SwaggerGen;
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Configuration;
+using TarninaHub.Errors;
+using TarninaHub.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
-var surrealSettings = builder.Configuration.GetSection("SurrealDb").Get()
- ?? new SurrealDbSettings();
-
-builder.Services.Configure(builder.Configuration.GetSection("SurrealDb"));
-
-// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
-builder.Services.AddOpenApi();
+// Layered infrastructure: SurrealDB client, settings and persistence services.
+builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddControllers();
-builder.Services.AddSingleton();
+// Uniform error responses (RFC 7807) and a global exception handler.
+builder.Services.AddProblemDetails();
+builder.Services.AddExceptionHandler();
-var surrealOptions = new SurrealDbOptions
-{
- Endpoint = BuildEndpoint(surrealSettings),
- Namespace = surrealSettings.Namespace,
- Database = surrealSettings.Database,
-};
+// API versioning: `?api-version=1.0` or header `X-Api-Version`; default 1.0.
+builder.Services
+ .AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionReader = ApiVersionReader.Combine(
+ new QueryStringApiVersionReader("api-version"),
+ new HeaderApiVersionReader("X-Api-Version"));
+ options.ReportApiVersions = true;
+ })
+ .AddMvc()
+ .AddApiExplorer(options =>
+ {
+ options.GroupNameFormat = "'v'VVV";
+ options.SubstituteApiVersionInUrl = true;
+ });
-if (string.IsNullOrWhiteSpace(surrealSettings.RemoteUrl))
-{
- // Embedded RocksDB, persisted next to the running app (./data), never in memory.
- var dataDirectory = Path.Combine(AppContext.BaseDirectory, surrealSettings.DataDirectory);
- Directory.CreateDirectory(dataDirectory);
-
- builder.Services
- .AddSurreal(surrealOptions, ServiceLifetime.Singleton)
- .AddRocksDbProvider();
-}
-else
-{
- // Remote SurrealDB server on its own dedicated port (used for sync with the frontend).
- surrealOptions.Username = surrealSettings.Username;
- surrealOptions.Password = surrealSettings.Password;
-
- builder.Services.AddSurreal(surrealOptions, ServiceLifetime.Singleton);
-}
+// OpenAPI document + Swagger UI (Swashbuckle), one document per API version.
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddTransient, ConfigureSwaggerOptions>();
+builder.Services.AddSwaggerGen();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
+ var migrationRunner = scope.ServiceProvider.GetRequiredService();
var storageService = scope.ServiceProvider.GetRequiredService();
+ await migrationRunner.ApplyAsync(CancellationToken.None);
await storageService.InitializeAsync(CancellationToken.None);
}
-// Configure the HTTP request pipeline.
-if (app.Environment.IsDevelopment())
+app.UseExceptionHandler();
+app.UseStatusCodePages();
+
+app.UseSwagger();
+app.UseSwaggerUI(options =>
{
- app.MapOpenApi();
-}
-else
+ var provider = app.Services.GetRequiredService();
+
+ foreach (var description in provider.ApiVersionDescriptions)
+ {
+ options.SwaggerEndpoint(
+ $"/swagger/{description.GroupName}/swagger.json",
+ $"TarninaHub API {description.GroupName.ToUpperInvariant()}");
+ }
+});
+
+if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
@@ -68,15 +81,3 @@ app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
-
-static string BuildEndpoint(SurrealDbSettings settings)
-{
- if (!string.IsNullOrWhiteSpace(settings.RemoteUrl))
- {
- return settings.RemoteUrl;
- }
-
- var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
-
- return $"rocksdb://{dataDirectory.Replace('\\', '/')}";
-}
diff --git a/backend/TarninaHub.csproj b/backend/TarninaHub.csproj
index 77db9f4..1081262 100644
--- a/backend/TarninaHub.csproj
+++ b/backend/TarninaHub.csproj
@@ -9,9 +9,30 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+