Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions Kromer/Controllers/V1/SubscriptionsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using Kromer.Models.Api.V1;
using Kromer.Models.Api.V1.Subscriptions;
using Kromer.Models.Dto;
using Kromer.Models.Exceptions;
using Kromer.Repositories;
using Microsoft.AspNetCore.Mvc;

namespace Kromer.Controllers.V1;

[Route("api/v1/subscriptions")]
[ApiController]
public class SubscriptionsController(SubscriptionRepository subscriptionRepository) : ControllerBase
{
/// <summary>
/// Creates a subscription contract for the supplied name or metaname.
/// </summary>
/// <param name="request">The contract details and private key of the current name owner.</param>
/// <returns>The identifier of the created subscription contract.</returns>
/// <exception cref="KromerException">Thrown when the request is invalid, authentication fails, or the name does not exist.</exception>
[HttpPost("")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<CreateSubscriptionResponse>>> CreateSubscription(
[FromBody] CreateSubscriptionRequest? request)
{
return new Result<CreateSubscriptionResponse>(
await subscriptionRepository.CreateContractAsync(request));
}

/// <summary>
/// Cancels a subscription contract and all active wallet subscriptions attached to it.
/// </summary>
/// <param name="id">The identifier of the subscription contract to cancel.</param>
/// <param name="request">The private key of the current name owner.</param>
/// <returns>The cancelled subscription contract.</returns>
/// <exception cref="KromerException">Thrown when the contract is not found, the private key is invalid, or the caller does not own the name.</exception>
[HttpDelete("{id:int}")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<SubscriptionDto>>> DeleteSubscription(int id,
[FromBody] PrivateKeyRequest? request)
{
return new Result<SubscriptionDto>(
await subscriptionRepository.CancelContractAsync(id, request?.PrivateKey));
}

/// <summary>
/// Closes a subscription contract to new subscribers while keeping existing subscriptions active.
/// </summary>
/// <param name="id">The identifier of the subscription contract to close.</param>
/// <param name="request">The private key of the current name owner.</param>
/// <returns>The closed subscription contract.</returns>
/// <exception cref="KromerException">Thrown when the contract is not found, the private key is invalid, or the caller does not own the name.</exception>
[HttpPost("{id:int}/close")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<SubscriptionDto>>> CloseSubscription(int id,
[FromBody] PrivateKeyRequest? request)
{
return new Result<SubscriptionDto>(
await subscriptionRepository.CloseContractAsync(id, request?.PrivateKey));
}

/// <summary>
/// Retrieves a subscription contract by its identifier.
/// </summary>
/// <param name="id">The identifier of the subscription contract to retrieve.</param>
/// <param name="addresses">Optional wallet addresses used to include caller-specific subscription state.</param>
/// <returns>The subscription contract details.</returns>
/// <exception cref="KromerException">Thrown when the contract is not found or the address is invalid.</exception>
[HttpGet("{id:int}")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<SubscriptionDto>>> GetSubscription(int id,
[FromQuery(Name = "address")] List<string>? addresses = null)
{
return new Result<SubscriptionDto>(
await subscriptionRepository.GetContractAsync(id, addresses));
}

/// <summary>
/// Subscribes the authenticated wallet to a subscription contract.
/// </summary>
/// <param name="id">The identifier of the subscription contract to subscribe to.</param>
/// <param name="request">The private key of the subscribing wallet.</param>
/// <returns>The next payment date for the wallet subscription.</returns>
/// <exception cref="KromerException">Thrown when authentication fails or the wallet cannot subscribe to the contract.</exception>
[HttpPost("{id:int}/subscribe")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<SubscribeResponse>>> Subscribe(int id, [FromBody] PrivateKeyRequest? request)
{
return new Result<SubscribeResponse>(
await subscriptionRepository.SubscribeAsync(id, request?.PrivateKey));
}

/// <summary>
/// Unsubscribes the authenticated wallet from a subscription contract.
/// </summary>
/// <param name="id">The identifier of the subscription contract to unsubscribe from.</param>
/// <param name="request">The private key of the subscribed wallet.</param>
/// <returns>An empty result when the unsubscribe request has been processed.</returns>
/// <exception cref="KromerException">Thrown when authentication fails, the contract is not found, or the subscription cannot be unsubscribed.</exception>
[HttpPost("{id:int}/unsubscribe")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<object>>> Unsubscribe(int id, [FromBody] PrivateKeyRequest? request)
{
await subscriptionRepository.UnsubscribeAsync(id, request?.PrivateKey);
return new Result<object>(new { });
}

/// <summary>
/// Lists subscription contracts related to a wallet address or name.
/// </summary>
/// <param name="addresses">The wallet addresses used to find owned and subscribed contracts.</param>
/// <param name="names">The names or metanames used to filter contracts by receiver.</param>
/// <param name="excludeOwned">Excludes contracts owned by the supplied addresses when set.</param>
/// <param name="onlyOwned">Only includes contracts owned by the supplied addresses when set.</param>
/// <param name="onlyUnsubscribable">Only includes active wallet subscriptions that can currently be unsubscribed.</param>
/// <param name="limit">The maximum number of contracts to return. The value is between 1 and 1000!</param>
/// <param name="offset">The number of contracts to skip before returning results.</param>
/// <returns>A paginated list of subscription contracts.</returns>
/// <exception cref="KromerException">Thrown when neither address nor name is supplied, or when supplied filters are invalid.</exception>
[HttpGet("")]
Comment thread
bananasov marked this conversation as resolved.
public async Task<ActionResult<Result<SubscriptionListResponse>>> ListSubscriptions(
[FromQuery(Name = "address")] List<string>? addresses = null,
[FromQuery(Name = "name")] List<string>? names = null,
[FromQuery(Name = "exclude_owned")] bool excludeOwned = false,
[FromQuery(Name = "only_owned")] bool onlyOwned = false,
[FromQuery(Name = "only_unsubscribable")] bool onlyUnsubscribable = true,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0)
{
return new Result<SubscriptionListResponse>(
await subscriptionRepository.ListContractsAsync(addresses, names, excludeOwned, onlyOwned,
onlyUnsubscribable, limit, offset));
}
}
83 changes: 81 additions & 2 deletions Kromer/Data/KromerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,21 @@ public KromerContext(DbContextOptions<KromerContext> options)

public virtual DbSet<PlayerEntity> Players { get; set; }

public virtual DbSet<SubscriptionContractEntity> SubscriptionContracts { get; set; }

public virtual DbSet<TransactionEntity> Transactions { get; set; }

public virtual DbSet<WalletEntity> Wallets { get; set; }

public virtual DbSet<WalletSubscriptionEntity> WalletSubscriptions { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.HasPostgresEnum("transaction_type",
new[] { "mined", "unknown", "name_purchase", "name_a_record", "name_transfer", "transfer" })
.HasPostgresEnum("transaction_type_enum", new[] { "unknown", "mined", "name_purchase", "transfer" });
.HasPostgresEnum("transaction_type_enum", new[] { "unknown", "mined", "name_purchase", "transfer" })
.HasPostgresEnum("subscription_status", new[] { "active", "closed", "cancelled" });

modelBuilder.Entity<NameEntity>(entity =>
{
Expand Down Expand Up @@ -114,6 +119,41 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasColumnType("public.transaction_type");
});

modelBuilder.Entity<SubscriptionContractEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("subscription_contracts_pkey");

entity.ToTable("subscription_contracts");

entity.HasIndex(e => e.BaseName, "idx_subscription_contract_base_name");
entity.HasIndex(e => e.Receiver, "idx_subscription_contract_receiver");
entity.HasIndex(e => e.Status, "idx_subscription_contract_status");

entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.Receiver)
.HasMaxLength(98)
.HasColumnName("receiver");
entity.Property(e => e.BaseName)
.HasMaxLength(64)
.HasColumnName("base_name");
entity.Property(e => e.Price)
.HasPrecision(16, 5)
.HasColumnName("price");
entity.Property(e => e.PeriodMinutes).HasColumnName("period_minutes");
entity.Property(e => e.Description)
.HasMaxLength(255)
.HasColumnName("description");
entity.Property(e => e.MaxSubscribers).HasColumnName("max_subscribers");
entity.Property(e => e.AllowedSubscriberAddresses)
.HasColumnType("text[]")
.HasColumnName("allowed_subscriber_addresses");
entity.Property(e => e.Status)
.HasColumnName("status")
.HasColumnType("public.subscription_status");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.CancelledAt).HasColumnName("cancelled_at");
});

modelBuilder.Entity<WalletEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("wallets_pkey");
Expand Down Expand Up @@ -145,8 +185,47 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasColumnName("total_out");
});

modelBuilder.Entity<WalletSubscriptionEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("wallet_subscriptions_pkey");

entity.ToTable("wallet_subscriptions");

entity.HasIndex(e => e.ContractId, "idx_wallet_subscription_contract_id");
entity.HasIndex(e => e.WalletAddress, "idx_wallet_subscription_wallet_address");
entity.HasIndex(e => e.NextPayment, "idx_wallet_subscription_next_payment");
entity.HasIndex(e => new { e.ContractId, e.WalletAddress }, "unique_active_wallet_subscription")
.IsUnique()
.HasFilter("status = 'active'");

entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.ContractId).HasColumnName("contract_id");
entity.Property(e => e.WalletAddress)
.HasMaxLength(10)
.IsFixedLength()
.HasColumnName("wallet_address");
entity.Property(e => e.NextPayment).HasColumnName("next_payment");
entity.Property(e => e.Status)
.HasColumnName("status")
.HasColumnType("public.subscription_status");
entity.Property(e => e.CancellationReason)
.HasMaxLength(64)
.HasColumnName("cancellation_reason");
entity.Property(e => e.CanUnsubscribe)
.HasDefaultValue(true)
.HasColumnName("can_unsubscribe");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.CancelledAt).HasColumnName("cancelled_at");

entity.HasOne(e => e.Contract)
.WithMany(e => e.WalletSubscriptions)
.HasForeignKey(e => e.ContractId)
.HasConstraintName("wallet_subscriptions_contract_id_fkey")
.OnDelete(DeleteBehavior.Cascade);
});

OnModelCreatingPartial(modelBuilder);
}

partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
}
130 changes: 130 additions & 0 deletions Kromer/Migrations/20260427000000_AddSubscriptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using Kromer.Data;
using Kromer.Models.Entities;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

#nullable disable

namespace Kromer.Migrations;

[DbContext(typeof(KromerContext))]
[Migration("20260427000000_AddSubscriptions")]
public partial class AddSubscriptions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscription_status') THEN
CREATE TYPE public.subscription_status AS ENUM ('active', 'closed', 'cancelled');
ELSIF NOT EXISTS (
SELECT 1
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'subscription_status' AND e.enumlabel = 'closed'
) THEN
ALTER TYPE public.subscription_status ADD VALUE 'closed' BEFORE 'cancelled';
END IF;
END
$$;
""");

migrationBuilder.CreateTable(
name: "subscription_contracts",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
receiver = table.Column<string>(type: "character varying(98)", maxLength: 98, nullable: false),
base_name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
price = table.Column<decimal>(type: "numeric(16,5)", precision: 16, scale: 5, nullable: false),
period_minutes = table.Column<int>(type: "integer", nullable: false),
description = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
max_subscribers = table.Column<int>(type: "integer", nullable: true),
allowed_subscriber_addresses = table.Column<string[]>(type: "text[]", nullable: true),
status = table.Column<SubscriptionStatus>(type: "public.subscription_status", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
cancelled_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("subscription_contracts_pkey", x => x.id);
});

migrationBuilder.CreateTable(
name: "wallet_subscriptions",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
contract_id = table.Column<int>(type: "integer", nullable: false),
wallet_address = table.Column<string>(type: "character(10)", fixedLength: true, maxLength: 10, nullable: false),
next_payment = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
status = table.Column<SubscriptionStatus>(type: "public.subscription_status", nullable: false),
cancellation_reason = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
can_unsubscribe = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
cancelled_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("wallet_subscriptions_pkey", x => x.id);
table.ForeignKey(
name: "wallet_subscriptions_contract_id_fkey",
column: x => x.contract_id,
principalTable: "subscription_contracts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});

migrationBuilder.CreateIndex(
name: "idx_subscription_contract_base_name",
table: "subscription_contracts",
column: "base_name");

migrationBuilder.CreateIndex(
name: "idx_subscription_contract_receiver",
table: "subscription_contracts",
column: "receiver");

migrationBuilder.CreateIndex(
name: "idx_subscription_contract_status",
table: "subscription_contracts",
column: "status");

migrationBuilder.CreateIndex(
name: "idx_wallet_subscription_contract_id",
table: "wallet_subscriptions",
column: "contract_id");

migrationBuilder.CreateIndex(
name: "idx_wallet_subscription_next_payment",
table: "wallet_subscriptions",
column: "next_payment");

migrationBuilder.CreateIndex(
name: "idx_wallet_subscription_wallet_address",
table: "wallet_subscriptions",
column: "wallet_address");

migrationBuilder.CreateIndex(
name: "unique_active_wallet_subscription",
table: "wallet_subscriptions",
columns: new[] { "contract_id", "wallet_address" },
unique: true,
filter: "status = 'active'");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "wallet_subscriptions");

migrationBuilder.DropTable(
name: "subscription_contracts");

migrationBuilder.Sql("DROP TYPE IF EXISTS public.subscription_status;");
}
}
21 changes: 21 additions & 0 deletions Kromer/Models/Api/V1/Subscriptions/CreateSubscriptionRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;

namespace Kromer.Models.Api.V1.Subscriptions;

public class CreateSubscriptionRequest
{
[JsonPropertyName("privatekey")]
public required string PrivateKey { get; set; }

public required string Name { get; set; }

public decimal Price { get; set; }

public int Period { get; set; }

public required string Description { get; set; }

public int? MaxSubscribers { get; set; }

public List<string>? AllowedSubscribers { get; set; }
}
Loading