Compare commits

..

1 Commits

Author SHA1 Message Date
userAdityaa 41602e21c2 feat: display relay provisioning errors in UI 2026-04-20 18:08:38 +00:00
8 changed files with 59 additions and 151 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ Notes:
- Serves `POST /relays/:id/deactivate` - Serves `POST /relays/:id/deactivate`
- Authorizes admin or relay owner - Authorizes admin or relay owner
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive` - If relay is already inactive, return a `400` with `code=relay-is-inactive`
- Call `command.deactivate_relay` - Call `command.deactivate_relay`
- Return `data` is empty - Return `data` is empty
+4 -4
View File
@@ -28,7 +28,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
- Fetch the relay and tenant associated with the `activity` - Fetch the relay and tenant associated with the `activity`
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early. - **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early. - **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `active` and on a paid plan**: - **If relay is `active` and on a paid plan**:
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early. - **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`. - **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
@@ -85,7 +85,7 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- If tenant has `past_due_at` set: - If tenant has `past_due_at` set:
- Clear `past_due_at` via `command.clear_tenant_past_due` - Clear `past_due_at` via `command.clear_tenant_past_due`
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment) - Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
- Reactivate each one via `command.activate_relay` - Reactivate each one via `command.activate_relay`
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)` ## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
@@ -98,7 +98,7 @@ Skip invoices with `amount_due` of 0.
## `fn handle_invoice_overdue(&self, invoice: &Invoice)` ## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`) - Deactivate all active relays on paid plans via `command.deactivate_relay`
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment - Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
## `fn handle_subscription_updated(&self, subscription: &Subscription)` ## `fn handle_subscription_updated(&self, subscription: &Subscription)`
@@ -106,7 +106,7 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- If subscription status is `canceled` or `unpaid`: - If subscription status is `canceled` or `unpaid`:
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent` - Deactivate all active paid relays for the tenant via `command.deactivate_relay`
## `fn handle_subscription_deleted(&self, subscription: &Subscription)` ## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
-8
View File
@@ -44,14 +44,6 @@ Notes:
- Sets relay status to `inactive` - Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)` - Logs activity as `(deactivate_relay, relay_id)`
- Used for user/admin-initiated deactivation only
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent`
- Logs activity as `(deactivate_relay, relay_id)`
- Used exclusively by the billing system when a relay's subscription becomes past due
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
+1 -1
View File
@@ -69,7 +69,7 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
- `subdomain` - the relay's subdomain - `subdomain` - the relay's subdomain
- `plan` - the relay's plan - `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans. - `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay. - `status` - `active|inactive`. Only `active` relays count toward billing.
- `synced` - whether the relay has been successfully synced to zooid at least once. - `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing. - `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name - `info_name` - the relay's name
+51 -107
View File
@@ -251,16 +251,22 @@ impl Api {
} }
} }
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> { fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result<Relay> {
validate_subdomain_label(&relay.subdomain)?; if !relay
.subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(anyhow!("invalid-subdomain"));
}
let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?; let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
if !plan.blossom && relay.blossom_enabled == 1 { if !plan.blossom && relay.blossom_enabled == 1 {
return Err(RelayValidationError::PremiumFeature); return Err(anyhow!("premium-feature"));
} }
if !plan.livekit && relay.livekit_enabled == 1 { if !plan.livekit && relay.livekit_enabled == 1 {
return Err(RelayValidationError::PremiumFeature); return Err(anyhow!("premium-feature"));
} }
if relay.schema.is_empty() { if relay.schema.is_empty() {
@@ -283,96 +289,6 @@ impl Api {
} }
} }
const SUBDOMAIN_LABEL_MAX_LEN: usize = 63;
const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubdomainValidationError {
Empty,
TooLong,
Reserved,
EdgeHyphen,
InvalidCharacters,
}
impl SubdomainValidationError {
fn code(self) -> &'static str {
match self {
Self::Empty => "subdomain-empty",
Self::TooLong => "subdomain-too-long",
Self::Reserved => "subdomain-reserved",
Self::EdgeHyphen => "subdomain-invalid-hyphen",
Self::InvalidCharacters => "subdomain-invalid-characters",
}
}
fn message(self) -> &'static str {
match self {
Self::Empty => "subdomain is required",
Self::TooLong => "subdomain must be 63 characters or fewer",
Self::Reserved => "subdomain is reserved",
Self::EdgeHyphen => "subdomain cannot start or end with a hyphen",
Self::InvalidCharacters => {
"subdomain may only contain lowercase letters, numbers, and hyphens"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RelayValidationError {
InvalidPlan,
PremiumFeature,
Subdomain(SubdomainValidationError),
}
impl RelayValidationError {
fn code(self) -> &'static str {
match self {
Self::InvalidPlan => "invalid-plan",
Self::PremiumFeature => "premium-feature",
Self::Subdomain(reason) => reason.code(),
}
}
fn message(self) -> &'static str {
match self {
Self::InvalidPlan => "plan not found",
Self::PremiumFeature => "feature requires a paid plan",
Self::Subdomain(reason) => reason.message(),
}
}
}
impl From<SubdomainValidationError> for RelayValidationError {
fn from(value: SubdomainValidationError) -> Self {
Self::Subdomain(value)
}
}
fn validate_subdomain_label(subdomain: &str) -> std::result::Result<(), SubdomainValidationError> {
if subdomain.is_empty() {
return Err(SubdomainValidationError::Empty);
}
if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN {
return Err(SubdomainValidationError::TooLong);
}
if subdomain.starts_with('-') || subdomain.ends_with('-') {
return Err(SubdomainValidationError::EdgeHyphen);
}
if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) {
return Err(SubdomainValidationError::Reserved);
}
if !subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SubdomainValidationError::InvalidCharacters);
}
Ok(())
}
fn ok<T: Serialize>(status: StatusCode, data: T) -> Response { fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
(status, Json(OkResponse { data, code: "ok" })).into_response() (status, Json(OkResponse { data, code: "ok" })).into_response()
} }
@@ -400,14 +316,6 @@ fn parse_bool_default(value: i64, default: i64) -> i64 {
} }
} }
fn relay_validation_error_response(error: RelayValidationError) -> Response {
err(
StatusCode::UNPROCESSABLE_ENTITY,
error.code(),
error.message(),
)
}
fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> { fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?; let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else { let sqlx::Error::Database(db_err) = sqlx_err else {
@@ -700,9 +608,27 @@ async fn create_relay(
}; };
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(relay_validation_error_response(e)); return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"premium-feature",
"feature requires a paid plan",
));
}
Err(_) => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-relay",
"relay validation failed",
));
} }
}; };
@@ -786,9 +712,27 @@ async fn update_relay(
} }
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(relay_validation_error_response(e)); return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"premium-feature",
"feature requires a paid plan",
));
}
Err(_) => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-relay",
"relay validation failed",
));
} }
}; };
-7
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, For } from "solid-js" import { createEffect, createMemo, createSignal, For } from "solid-js"
import type { Relay } from "@/lib/hooks" import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify" import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state" import { plans } from "@/lib/state"
@@ -32,12 +31,6 @@ export default function RelayForm(props: RelayFormProps) {
return return
} }
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("") setToastMessage("")
setSubmitting(true) setSubmitting(true)
-22
View File
@@ -1,22 +0,0 @@
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
export function validateSubdomainLabel(subdomain: string): string | null {
if (subdomain.length === 0) {
return "subdomain is required"
}
if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) {
return "subdomain must be 63 characters or fewer"
}
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
return "subdomain cannot start or end with a hyphen"
}
if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) {
return "subdomain is reserved"
}
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return "subdomain may only contain lowercase letters, numbers, and hyphens"
}
return null
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js" import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
@@ -17,7 +18,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), { await updateRelayById(relayId(), {
subdomain: values.subdomain, subdomain: slugify(values.subdomain),
info_name: values.info_name.trim(), info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(), info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(), info_description: values.info_description.trim(),