forked from coracle/caravel
Compare commits
2 Commits
add-docs
...
f11d23d23e
| Author | SHA1 | Date | |
|---|---|---|---|
| f11d23d23e | |||
| d209353abd |
+1
-1
@@ -132,7 +132,7 @@ Notes:
|
||||
|
||||
- Serves `POST /relays/:id/deactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay is already inactive, return a `400` with `code=relay-is-inactive`
|
||||
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
|
||||
- Call `command.deactivate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
|
||||
@@ -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`
|
||||
- **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`**: 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 `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.
|
||||
- **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`
|
||||
- If tenant has `past_due_at` set:
|
||||
- Clear `past_due_at` via `command.clear_tenant_past_due`
|
||||
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
|
||||
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
|
||||
- Reactivate each one via `command.activate_relay`
|
||||
|
||||
## `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)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Deactivate all active relays on paid plans via `command.deactivate_relay`
|
||||
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
|
||||
- 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)`
|
||||
@@ -106,7 +106,7 @@ Skip invoices with `amount_due` of 0.
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- If subscription status is `canceled` or `unpaid`:
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
|
||||
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
|
||||
|
||||
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ Notes:
|
||||
|
||||
- Sets relay status to `inactive`
|
||||
- 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<()>`
|
||||
|
||||
|
||||
@@ -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
|
||||
- `plan` - the relay's plan
|
||||
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
|
||||
- `status` - `active|inactive`. Only `active` relays count toward billing.
|
||||
- `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.
|
||||
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||
- `info_name` - the relay's name
|
||||
|
||||
+107
-51
@@ -251,22 +251,16 @@ impl Api {
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result<Relay> {
|
||||
if !relay
|
||||
.subdomain
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(anyhow!("invalid-subdomain"));
|
||||
}
|
||||
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
|
||||
validate_subdomain_label(&relay.subdomain)?;
|
||||
|
||||
let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
|
||||
let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?;
|
||||
|
||||
if !plan.blossom && relay.blossom_enabled == 1 {
|
||||
return Err(anyhow!("premium-feature"));
|
||||
return Err(RelayValidationError::PremiumFeature);
|
||||
}
|
||||
if !plan.livekit && relay.livekit_enabled == 1 {
|
||||
return Err(anyhow!("premium-feature"));
|
||||
return Err(RelayValidationError::PremiumFeature);
|
||||
}
|
||||
|
||||
if relay.schema.is_empty() {
|
||||
@@ -289,6 +283,96 @@ 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 {
|
||||
(status, Json(OkResponse { data, code: "ok" })).into_response()
|
||||
}
|
||||
@@ -316,6 +400,14 @@ 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> {
|
||||
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
|
||||
let sqlx::Error::Database(db_err) = sqlx_err else {
|
||||
@@ -608,27 +700,9 @@ async fn create_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,
|
||||
Err(e) if e.to_string() == "premium-feature" => {
|
||||
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",
|
||||
));
|
||||
Err(e) => {
|
||||
return Ok(relay_validation_error_response(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -712,27 +786,9 @@ async fn update_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,
|
||||
Err(e) if e.to_string() == "premium-feature" => {
|
||||
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",
|
||||
));
|
||||
Err(e) => {
|
||||
return Ok(relay_validation_error_response(e));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createEffect, createMemo, createSignal, For } from "solid-js"
|
||||
import type { Relay } from "@/lib/hooks"
|
||||
import { slugify } from "@/lib/slugify"
|
||||
import { validateSubdomainLabel } from "@/lib/subdomain"
|
||||
import { setToastMessage } from "@/components/Toast"
|
||||
import { plans } from "@/lib/state"
|
||||
|
||||
@@ -31,6 +32,12 @@ export default function RelayForm(props: RelayFormProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const subdomainError = validateSubdomainLabel(subdomain())
|
||||
if (subdomainError) {
|
||||
setToastMessage(subdomainError)
|
||||
return
|
||||
}
|
||||
|
||||
setToastMessage("")
|
||||
setSubmitting(true)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||
import { slugify } from "@/lib/slugify"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
@@ -18,7 +17,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
await updateRelayById(relayId(), {
|
||||
subdomain: slugify(values.subdomain),
|
||||
subdomain: values.subdomain,
|
||||
info_name: values.info_name.trim(),
|
||||
info_icon: values.info_icon.trim(),
|
||||
info_description: values.info_description.trim(),
|
||||
|
||||
Reference in New Issue
Block a user