feat: implement freemium restrictions
This commit is contained in:
@@ -149,6 +149,9 @@ func main() {
|
|||||||
mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession))
|
mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession))
|
||||||
mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession))
|
mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession))
|
||||||
|
|
||||||
|
// Freemium usage (any network member)
|
||||||
|
mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage))
|
||||||
|
|
||||||
// Network Invitations
|
// Network Invitations
|
||||||
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
|
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
|
||||||
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
|
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
@@ -43,6 +45,7 @@ func main() {
|
|||||||
db.Init()
|
db.Init()
|
||||||
defer db.Cleanup()
|
defer db.Cleanup()
|
||||||
processingRepo := particle.NewProcessingRepository(db.Pool())
|
processingRepo := particle.NewProcessingRepository(db.Pool())
|
||||||
|
billingSvc := billing.NewServiceForWorker(db.Pool())
|
||||||
|
|
||||||
storageClient, err := storage.NewClient(ctx)
|
storageClient, err := storage.NewClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +107,7 @@ func main() {
|
|||||||
|
|
||||||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
updateParentLastChildCreatedAt(ctx, change.Doc)
|
||||||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||||
|
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||||
|
|
||||||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||||
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
||||||
@@ -192,6 +196,59 @@ func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordFreemiumUsage bumps the network's daily message counter for non-container
|
||||||
|
// particles. Idempotent via the surrounding processed_particles guard: the worker
|
||||||
|
// only reaches this path on first-seen particles, so a crash/restart won't
|
||||||
|
// double-count.
|
||||||
|
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
|
||||||
|
rawType, err := doc.DataAt("type")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typeStr, ok := rawType.(string)
|
||||||
|
if !ok {
|
||||||
|
slog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
particleType, err := particle.ParseParticleType(typeStr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Containers (stream/folder) don't count as "messages" for the daily cap.
|
||||||
|
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID, err := networkIDFromParticlePath(doc.Ref.Path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil {
|
||||||
|
slog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkIDFromParticlePath extracts the network id from a Firestore particle
|
||||||
|
// document path. Particles live at `networks/{network_id}/children/.../children/{id}`
|
||||||
|
// at arbitrary nesting depth, so the network id is always the second segment
|
||||||
|
// of the full doc path (which itself is rooted under the Firestore db path:
|
||||||
|
// `projects/.../documents/networks/{network_id}/...`).
|
||||||
|
func networkIDFromParticlePath(path string) (string, error) {
|
||||||
|
// doc.Ref.Path is the full resource path; find the "networks" collection
|
||||||
|
// and return the next segment.
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
for i, seg := range segments {
|
||||||
|
if seg == "networks" && i+1 < len(segments) {
|
||||||
|
return segments[i+1], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no networks segment in path: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
||||||
// to the child's actual created_at timestamp, so it stays directly comparable with
|
// to the child's actual created_at timestamp, so it stays directly comparable with
|
||||||
// playback markers (which also store child created_at values).
|
// playback markers (which also store child created_at values).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package billing
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Noop returns a billing service for binaries that depend on network.Service
|
// Noop returns a billing service for binaries that depend on network.Service
|
||||||
@@ -24,3 +25,9 @@ func (noopService) CreatePortalSession(context.Context, string) (string, error)
|
|||||||
}
|
}
|
||||||
func (noopService) SyncSeats(context.Context, string, int) error { return nil }
|
func (noopService) SyncSeats(context.Context, string, int) error { return nil }
|
||||||
func (noopService) HandleWebhook(context.Context, []byte, string) error { return errNoopBilling }
|
func (noopService) HandleWebhook(context.Context, []byte, string) error { return errNoopBilling }
|
||||||
|
func (noopService) GetUsage(context.Context, string) (*Usage, error) {
|
||||||
|
return nil, errNoopBilling
|
||||||
|
}
|
||||||
|
func (noopService) IncrementDailyUsage(context.Context, string, time.Time) error {
|
||||||
|
return errNoopBilling
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stripe/stripe-go/v85"
|
"github.com/stripe/stripe-go/v85"
|
||||||
@@ -22,6 +23,14 @@ type Service interface {
|
|||||||
// No-op if the network has no active subscription.
|
// No-op if the network has no active subscription.
|
||||||
SyncSeats(ctx context.Context, networkID string, seats int) error
|
SyncSeats(ctx context.Context, networkID string, seats int) error
|
||||||
HandleWebhook(ctx context.Context, payload []byte, signature string) error
|
HandleWebhook(ctx context.Context, payload []byte, signature string) error
|
||||||
|
|
||||||
|
// GetUsage reports today's freemium quota state for a network.
|
||||||
|
// Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit.
|
||||||
|
GetUsage(ctx context.Context, networkID string) (*Usage, error)
|
||||||
|
// IncrementDailyUsage is called by the particle processor worker for each
|
||||||
|
// qualifying particle (non-container). Idempotency is the caller's concern
|
||||||
|
// — the worker guards this via processed_particles.
|
||||||
|
IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -32,6 +41,7 @@ var (
|
|||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
repo repository
|
repo repository
|
||||||
|
usageRepo usageRepository
|
||||||
priceMonthlyCents int64
|
priceMonthlyCents int64
|
||||||
priceAnnualCents int64
|
priceAnnualCents int64
|
||||||
}
|
}
|
||||||
@@ -58,11 +68,22 @@ func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, e
|
|||||||
return &serviceImpl{
|
return &serviceImpl{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
repo: newRepository(pool),
|
repo: newRepository(pool),
|
||||||
|
usageRepo: newUsageRepository(pool),
|
||||||
priceMonthlyCents: monthly.UnitAmount,
|
priceMonthlyCents: monthly.UnitAmount,
|
||||||
priceAnnualCents: annual.UnitAmount,
|
priceAnnualCents: annual.UnitAmount,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewServiceForWorker builds a minimal billing Service suitable for the
|
||||||
|
// particle processor worker: only the usage-tracking path is exercised, so
|
||||||
|
// we skip Stripe client setup (no API key required).
|
||||||
|
func NewServiceForWorker(pool *pgxpool.Pool) Service {
|
||||||
|
return &serviceImpl{
|
||||||
|
usageRepo: newUsageRepository(pool),
|
||||||
|
repo: newRepository(pool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
|
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
|
||||||
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
if err != nil && !errors.Is(err, errNotFound) {
|
if err != nil && !errors.Is(err, errNotFound) {
|
||||||
@@ -208,6 +229,59 @@ func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
|
||||||
|
return s.usageRepo.incrementDaily(ctx, networkID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) {
|
||||||
|
now := time.Now()
|
||||||
|
used, err := s.usageRepo.getDaily(ctx, networkID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read daily usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := s.resolvePlan(ctx, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &Usage{
|
||||||
|
Plan: plan,
|
||||||
|
Used: used,
|
||||||
|
ResetAt: nextUTCMidnight(now),
|
||||||
|
}
|
||||||
|
if plan == PlanFree {
|
||||||
|
limit := FreemiumDailyLimit
|
||||||
|
u.Limit = &limit
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePlan is a lightweight read: it infers free/pro from the local
|
||||||
|
// subscription row without hitting Stripe, so it is safe to call from the
|
||||||
|
// particle processor worker (no Stripe client required).
|
||||||
|
func (s *serviceImpl) resolvePlan(ctx context.Context, networkID string) (Plan, error) {
|
||||||
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return PlanFree, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch stripe.SubscriptionStatus(sub.Status) {
|
||||||
|
case stripe.SubscriptionStatusActive,
|
||||||
|
stripe.SubscriptionStatusTrialing,
|
||||||
|
stripe.SubscriptionStatusPastDue:
|
||||||
|
return PlanPro, nil
|
||||||
|
}
|
||||||
|
return PlanFree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextUTCMidnight(now time.Time) time.Time {
|
||||||
|
utc := now.UTC()
|
||||||
|
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
|
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
|
||||||
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
|
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// FreemiumDailyLimit is the per-network daily cap on non-container
|
||||||
|
// particles for networks on the free plan.
|
||||||
|
const FreemiumDailyLimit = 50
|
||||||
|
|
||||||
|
// Usage describes a network's current freemium quota state for today.
|
||||||
|
type Usage struct {
|
||||||
|
Plan Plan `json:"plan"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Limit *int `json:"limit"` // nil = unlimited (pro)
|
||||||
|
ResetAt time.Time `json:"reset_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type usageRepository interface {
|
||||||
|
incrementDaily(ctx context.Context, networkID string, at time.Time) error
|
||||||
|
getDaily(ctx context.Context, networkID string, at time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageRepositoryImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUsageRepository(pool *pgxpool.Pool) usageRepository {
|
||||||
|
return &usageRepositoryImpl{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *usageRepositoryImpl) incrementDaily(ctx context.Context, networkID string, at time.Time) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO network_message_usage (network_id, usage_date, message_count, updated_at)
|
||||||
|
VALUES ($1, ($2 AT TIME ZONE 'UTC')::date, 1, NOW())
|
||||||
|
ON CONFLICT (network_id, usage_date) DO UPDATE
|
||||||
|
SET message_count = network_message_usage.message_count + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
`, networkID, at)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *usageRepositoryImpl) getDaily(ctx context.Context, networkID string, at time.Time) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT message_count FROM network_message_usage
|
||||||
|
WHERE network_id = $1 AND usage_date = ($2 AT TIME ZONE 'UTC')::date
|
||||||
|
`, networkID, at).Scan(&count)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -25,6 +25,46 @@ type PortalSessionResponse struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNetworkUsage returns the freemium quota state for the authenticated
|
||||||
|
// caller's current network: how many messages they've used today, the daily
|
||||||
|
// limit (null for pro), and when the counter resets.
|
||||||
|
//
|
||||||
|
// Authorization: any network member may read (not admin-only) since the UI
|
||||||
|
// surfaces this to every sender.
|
||||||
|
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := r.PathValue("id")
|
||||||
|
if networkID == "" {
|
||||||
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to check network membership", "error", err, "network_id", networkID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := h.billingSvc.GetUsage(r.Context(), networkID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get network usage", "error", err, "network_id", networkID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, usage)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) {
|
||||||
net, _, ok := h.loadNetworkForAdmin(w, r)
|
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS network_message_usage;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE network_message_usage (
|
||||||
|
network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
|
||||||
|
usage_date DATE NOT NULL,
|
||||||
|
message_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (network_id, usage_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ListInvitationsResponseSchema,
|
ListInvitationsResponseSchema,
|
||||||
ListNetworksResponseSchema,
|
ListNetworksResponseSchema,
|
||||||
NetworkSchema,
|
NetworkSchema,
|
||||||
|
NetworkUsageSchema,
|
||||||
PortalSessionResponseSchema,
|
PortalSessionResponseSchema,
|
||||||
PrepareUploadResponseSchema,
|
PrepareUploadResponseSchema,
|
||||||
SignInResponseSchema,
|
SignInResponseSchema,
|
||||||
@@ -247,6 +248,14 @@ class ApiClient {
|
|||||||
`/networks/${networkId}/billing/portal-session`,
|
`/networks/${networkId}/billing/portal-session`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getNetworkUsage(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
NetworkUsageSchema,
|
||||||
|
"GET",
|
||||||
|
`/networks/${networkId}/usage`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient({
|
export const apiClient = new ApiClient({
|
||||||
|
|||||||
@@ -304,3 +304,11 @@ export const PortalSessionResponseSchema = z.object({
|
|||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
});
|
});
|
||||||
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||||
|
|
||||||
|
export const NetworkUsageSchema = z.object({
|
||||||
|
plan: NetworkPlanSchema,
|
||||||
|
used: z.number().int().nonnegative(),
|
||||||
|
limit: z.number().int().nonnegative().nullable(),
|
||||||
|
reset_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
import { QuotaExceededError, useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||||
|
import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage";
|
||||||
import { useRecorder } from "@/features/compose/use-recorder";
|
import { useRecorder } from "@/features/compose/use-recorder";
|
||||||
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
||||||
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||||
@@ -70,12 +71,17 @@ export function ComposeOverlay({
|
|||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const createParticle = useCreateParticle();
|
const createParticle = useCreateParticle();
|
||||||
const createStream = useCreateStreamParticle();
|
const createStream = useCreateStreamParticle();
|
||||||
|
const { data: usage } = useNetworkUsage(networkId);
|
||||||
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
|
const quotaExhausted = isUsageExhausted(usage);
|
||||||
|
|
||||||
// Refs for synchronous reads in keyboard handlers
|
// Refs for synchronous reads in keyboard handlers
|
||||||
const stepRef = useRef(step);
|
const stepRef = useRef(step);
|
||||||
const recordStartRef = useRef(0);
|
const recordStartRef = useRef(0);
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
disabledRef.current = disabled;
|
||||||
|
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||||
|
quotaExhaustedRef.current = quotaExhausted;
|
||||||
const recordingSourceRef = useRef(recordingSource);
|
const recordingSourceRef = useRef(recordingSource);
|
||||||
recordingSourceRef.current = recordingSource;
|
recordingSourceRef.current = recordingSource;
|
||||||
|
|
||||||
@@ -88,7 +94,12 @@ export function ComposeOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveChange?.(step !== "idle");
|
onActiveChange?.(step !== "idle");
|
||||||
onStepChange?.(step);
|
onStepChange?.(step);
|
||||||
}, [step, onActiveChange, onStepChange]);
|
// Refresh quota when the overlay activates — user is about to send, so
|
||||||
|
// we want the most accurate count before the client-side gate kicks in.
|
||||||
|
if (step !== "idle") {
|
||||||
|
void invalidateUsage(networkId);
|
||||||
|
}
|
||||||
|
}, [step, onActiveChange, onStepChange, invalidateUsage, networkId]);
|
||||||
|
|
||||||
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||||
for (const a of items) {
|
for (const a of items) {
|
||||||
@@ -334,12 +345,25 @@ export function ComposeOverlay({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleQuotaError = useCallback((err: unknown): boolean => {
|
||||||
|
if (err instanceof QuotaExceededError) {
|
||||||
|
toast.error("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||||
|
cancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [cancel]);
|
||||||
|
|
||||||
// Reply mode: create particle directly under targetPath
|
// Reply mode: create particle directly under targetPath
|
||||||
const onSubmitReply = useEffectEvent(async () => {
|
const onSubmitReply = useEffectEvent(async () => {
|
||||||
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
||||||
setStepSync("submitting");
|
setStepSync("submitting");
|
||||||
await createChildParticle(targetPath);
|
try {
|
||||||
cancel();
|
await createChildParticle(targetPath);
|
||||||
|
cancel();
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleQuotaError(err)) throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// New stream mode: create stream + first child
|
// New stream mode: create stream + first child
|
||||||
@@ -348,21 +372,25 @@ export function ComposeOverlay({
|
|||||||
if (!userId || stepRef.current === "submitting") return;
|
if (!userId || stepRef.current === "submitting") return;
|
||||||
setStepSync("submitting");
|
setStepSync("submitting");
|
||||||
|
|
||||||
const streamId = await createStream.mutateAsync({
|
try {
|
||||||
networkId,
|
const streamId = await createStream.mutateAsync({
|
||||||
properties: {
|
networkId,
|
||||||
name: streamName,
|
properties: {
|
||||||
},
|
name: streamName,
|
||||||
createdByHumanId: userId,
|
},
|
||||||
visibleTo,
|
createdByHumanId: userId,
|
||||||
});
|
visibleTo,
|
||||||
|
});
|
||||||
|
|
||||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||||
await createChildParticle(streamChildrenPath);
|
await createChildParticle(streamChildrenPath);
|
||||||
|
|
||||||
cancel();
|
cancel();
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleQuotaError(err)) throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[networkId, userId, createParticle, createChildParticle, cancel],
|
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Keyboard handling ---
|
// --- Keyboard handling ---
|
||||||
@@ -397,6 +425,13 @@ export function ComposeOverlay({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (quotaExhaustedRef.current) {
|
||||||
|
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
|
||||||
|
e.preventDefault();
|
||||||
|
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (e.key === "`" && !e.repeat) {
|
if (e.key === "`" && !e.repeat) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
recordStartRef.current = Date.now();
|
recordStartRef.current = Date.now();
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||||
|
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks";
|
||||||
|
|
||||||
|
interface ComposeQuotaIndicatorProps {
|
||||||
|
networkId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOW_PROGRESS_AT_FRACTION = 0.7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces freemium quota state near compose:
|
||||||
|
* - Nothing below 70% used (avoid nagging).
|
||||||
|
* - A subtle progress pill between 70% and the limit.
|
||||||
|
* - A locked banner with an upgrade CTA once the limit is hit.
|
||||||
|
*
|
||||||
|
* Pro networks and any network still loading usage render nothing.
|
||||||
|
*/
|
||||||
|
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: usage } = useNetworkUsage(networkId);
|
||||||
|
const isAdmin = useIsNetworkAdmin(networkId);
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
|
||||||
|
if (!usage || usage.limit == null) return null;
|
||||||
|
|
||||||
|
const fraction = usage.used / usage.limit;
|
||||||
|
const exhausted = usage.used >= usage.limit;
|
||||||
|
|
||||||
|
if (exhausted) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto flex max-w-md flex-col items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 text-center shadow-lg backdrop-blur">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{isAdmin
|
||||||
|
? `You've reached today's ${usage.limit}-message limit`
|
||||||
|
: `This network reached today's ${usage.limit}-message limit`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
|
||||||
|
</div>
|
||||||
|
{isAdmin ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/${networkId}/settings?section=billing`)}
|
||||||
|
>
|
||||||
|
Upgrade to Pro
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Ask{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{network?.admin_human.email_prefix ?? "your admin"}
|
||||||
|
</span>{" "}
|
||||||
|
to upgrade to Pro
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fraction < SHOW_PROGRESS_AT_FRACTION) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"
|
||||||
|
title={`Resets ${formatResetRelative(usage.reset_at)} at ${formatResetAbsolute(usage.reset_at)}`}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{usage.used}/{usage.limit} today
|
||||||
|
</span>
|
||||||
|
<Progress value={fraction * 100} className="h-1 w-24" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResetRelative(resetAt: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = resetAt.getTime() - now.getTime();
|
||||||
|
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
|
||||||
|
if (hours < 1) return "soon";
|
||||||
|
if (hours === 1) return "in 1 hour";
|
||||||
|
return `in ${hours} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResetAbsolute(resetAt: Date): string {
|
||||||
|
// Shows the user their local wall-clock time for the UTC-midnight reset,
|
||||||
|
// so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
|
||||||
|
return resetAt.toLocaleTimeString(undefined, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
useCreatePortalSession,
|
useCreatePortalSession,
|
||||||
useNetworkBilling,
|
useNetworkBilling,
|
||||||
} from "@/hooks/use-billing";
|
} from "@/hooks/use-billing";
|
||||||
|
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||||
|
import { useIsNetworkAdmin } from "@/hooks/use-networks";
|
||||||
import type { BillingCadence, BillingStatus } from "@/api/types";
|
import type { BillingCadence, BillingStatus } from "@/api/types";
|
||||||
|
|
||||||
function formatCents(cents: number): string {
|
function formatCents(cents: number): string {
|
||||||
@@ -91,6 +93,70 @@ function CadenceOption({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatResetLocal(resetAt: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = resetAt.getTime() - now.getTime();
|
||||||
|
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
|
||||||
|
const absolute = resetAt.toLocaleTimeString(undefined, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
if (hours < 1) return `soon (${absolute})`;
|
||||||
|
if (hours === 1) return `in 1 hour (${absolute})`;
|
||||||
|
return `in ${hours} hours (${absolute})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only plan + quota summary, sourced from `/usage` (member-accessible).
|
||||||
|
* The `/billing` endpoint is admin-gated, so we can't use it for the
|
||||||
|
* everyone-visible summary.
|
||||||
|
*/
|
||||||
|
function PlanSummary({ networkId }: { networkId: string }) {
|
||||||
|
const { data: usage } = useNetworkUsage(networkId);
|
||||||
|
|
||||||
|
if (!usage) return null;
|
||||||
|
|
||||||
|
const isPro = usage.plan === "pro";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InfoRow
|
||||||
|
label="Plan"
|
||||||
|
value={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
|
||||||
|
<Badge variant={isPro ? "default" : "secondary"}>
|
||||||
|
{isPro ? "Pro" : "Free"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!isPro && (
|
||||||
|
<>
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
<InfoRow
|
||||||
|
label="Today's messages"
|
||||||
|
value={
|
||||||
|
usage && usage.limit != null ? (
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{usage.used} / {usage.limit}
|
||||||
|
</span>
|
||||||
|
<Muted className="text-xs">
|
||||||
|
Resets {formatResetLocal(usage.reset_at)}
|
||||||
|
</Muted>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Muted className="text-sm">—</Muted>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FreeBilling({
|
function FreeBilling({
|
||||||
networkId,
|
networkId,
|
||||||
billing,
|
billing,
|
||||||
@@ -115,16 +181,6 @@ function FreeBilling({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InfoRow
|
|
||||||
label="Plan"
|
|
||||||
value={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>Llink Free</span>
|
|
||||||
<Badge variant="secondary">Free</Badge>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Separator className="mx-4" />
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={cadence}
|
value={cadence}
|
||||||
onValueChange={(v) => setCadence(v as BillingCadence)}
|
onValueChange={(v) => setCadence(v as BillingCadence)}
|
||||||
@@ -201,21 +257,15 @@ function ProBilling({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="Plan"
|
label="Billing"
|
||||||
value={
|
value={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Llink Pro</span>
|
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
|
||||||
<PlanStatusBadge status={billing.plan_status} />
|
<PlanStatusBadge status={billing.plan_status} />
|
||||||
<Badge>Pro</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator className="mx-4" />
|
<Separator className="mx-4" />
|
||||||
<InfoRow
|
|
||||||
label="Billing"
|
|
||||||
value={`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
|
|
||||||
/>
|
|
||||||
<Separator className="mx-4" />
|
|
||||||
<InfoRow label="Seats" value={billing.seats} />
|
<InfoRow label="Seats" value={billing.seats} />
|
||||||
{renewal && (
|
{renewal && (
|
||||||
<>
|
<>
|
||||||
@@ -243,7 +293,29 @@ function ProBilling({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified billing section. Shows the plan + usage summary to every member,
|
||||||
|
* and the admin-only management controls (upgrade / portal) below.
|
||||||
|
*
|
||||||
|
* `/billing` is admin-gated, so the management controls are the only part
|
||||||
|
* that depends on it — members rely on `/usage` for the summary.
|
||||||
|
*/
|
||||||
export function BillingSection({ networkId }: { networkId: string }) {
|
export function BillingSection({ networkId }: { networkId: string }) {
|
||||||
|
const isAdmin = useIsNetworkAdmin(networkId);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PlanSummary networkId={networkId} />
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
<AdminBillingControls networkId={networkId} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminBillingControls({ networkId }: { networkId: string }) {
|
||||||
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
|
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
|
||||||
|
|
||||||
if (isLoading || !billing) {
|
if (isLoading || !billing) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { particlePath } from "@/lib/particle-path";
|
|||||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||||
|
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||||
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
||||||
@@ -75,6 +76,11 @@ export default function NetworkRoot() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||||
|
{!composeActive && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||||||
|
<ComposeQuotaIndicator networkId={networkId!} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<NetworkRootControls />
|
<NetworkRootControls />
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ export default function NetworkSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<SettingsGroup title="Billing">
|
||||||
|
<BillingSection networkId={networkId!} />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
<SettingsGroup title="Members">
|
<SettingsGroup title="Members">
|
||||||
{network?.humans.map((human, index) => (
|
{network?.humans.map((human, index) => (
|
||||||
<div key={human.id}>
|
<div key={human.id}>
|
||||||
@@ -220,12 +226,6 @@ export default function NetworkSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<Separator className="mt-4" />
|
|
||||||
|
|
||||||
<SettingsGroup title="Billing">
|
|
||||||
<BillingSection networkId={networkId!} />
|
|
||||||
</SettingsGroup>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
||||||
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
|
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
|
||||||
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
||||||
|
import {
|
||||||
|
isUsageExhausted,
|
||||||
|
networkUsageQueryKey,
|
||||||
|
useBumpNetworkUsage,
|
||||||
|
useInvalidateNetworkUsage,
|
||||||
|
} from "./use-network-usage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a free-plan network attempts to create a non-container particle
|
||||||
|
* after hitting its daily message limit. Callers should surface an upgrade
|
||||||
|
* prompt; compose UI should also disable triggers proactively via
|
||||||
|
* `useNetworkUsage` rather than relying on this throw.
|
||||||
|
*/
|
||||||
|
export class QuotaExceededError extends Error {
|
||||||
|
constructor(public readonly networkId: string) {
|
||||||
|
super("Daily message limit reached");
|
||||||
|
this.name = "QuotaExceededError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||||
// Path to which the new particle will be added as a child
|
// Path to which the new particle will be added as a child
|
||||||
@@ -12,16 +31,37 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateParticle() {
|
export function useCreateParticle() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const bumpUsage = useBumpNetworkUsage();
|
||||||
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (params: CreateParticleParams) => {
|
mutationFn: async (params: CreateParticleParams) => {
|
||||||
|
const { networkId } = parseParticlePath(params.path);
|
||||||
|
|
||||||
|
// Containers aren't counted server-side, so we don't block them.
|
||||||
|
if (!CONTAINER_TYPES.has(params.type)) {
|
||||||
|
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
|
||||||
|
if (isUsageExhausted(cached)) {
|
||||||
|
throw new QuotaExceededError(networkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const collectionPath = toFirestoreChildrenPath(params.path);
|
const collectionPath = toFirestoreChildrenPath(params.path);
|
||||||
return await createParticle(
|
const result = await createParticle(
|
||||||
collectionPath,
|
collectionPath,
|
||||||
params.type,
|
params.type,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByHumanId,
|
params.createdByHumanId,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
if (!CONTAINER_TYPES.has(params.type)) {
|
||||||
|
bumpUsage(networkId);
|
||||||
|
void invalidateUsage(networkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import type { NetworkUsage } from "@/api/types";
|
||||||
|
|
||||||
|
export const networkUsageQueryKey = (networkId: string | undefined) =>
|
||||||
|
["network-usage", networkId] as const;
|
||||||
|
|
||||||
|
export function useNetworkUsage(networkId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: networkUsageQueryKey(networkId),
|
||||||
|
queryFn: () => apiClient.getNetworkUsage(networkId!),
|
||||||
|
enabled: !!networkId,
|
||||||
|
// Refetch whenever a consumer mounts (billing settings, compose indicator)
|
||||||
|
// so users land on fresh quota state without listener wiring.
|
||||||
|
refetchOnMount: "always",
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a callback that invalidates the usage query for a network.
|
||||||
|
* Callers: own-send success path, inbound-particle listener.
|
||||||
|
*/
|
||||||
|
export function useInvalidateNetworkUsage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useCallback(
|
||||||
|
(networkId: string) =>
|
||||||
|
qc.invalidateQueries({ queryKey: networkUsageQueryKey(networkId) }),
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic bump of the cached `used` count. The worker-written truth is
|
||||||
|
* reconciled on the next invalidation/refetch.
|
||||||
|
*/
|
||||||
|
export function useBumpNetworkUsage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useCallback(
|
||||||
|
(networkId: string) => {
|
||||||
|
qc.setQueryData<NetworkUsage>(networkUsageQueryKey(networkId), (prev) =>
|
||||||
|
prev ? { ...prev, used: prev.used + 1 } : prev,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff the network is on the free plan and has exhausted today's quota.
|
||||||
|
*/
|
||||||
|
export function isUsageExhausted(usage: NetworkUsage | undefined): boolean {
|
||||||
|
if (!usage) return false;
|
||||||
|
if (usage.limit == null) return false;
|
||||||
|
return usage.used >= usage.limit;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
export function useNetworks() {
|
export function useNetworks() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -12,3 +13,10 @@ export function useNetwork(networkId: string) {
|
|||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
return networks?.find((n) => n.id === networkId) || null;
|
return networks?.find((n) => n.id === networkId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIsNetworkAdmin(networkId: string): boolean {
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
if (!network || !userId) return false;
|
||||||
|
return network.admin_human.id === userId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, us
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||||
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||||
|
|
||||||
// --- Playback reducer (ID-based) ---
|
// --- Playback reducer (ID-based) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user