From 7d0d36e7138a00cbab04cd9219af4dfa23509879 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 18:42:30 +0800 Subject: [PATCH 01/37] feat(membership): add memberships and billing_webhook_events schema, migration, repositories Adds the DB foundation for the paid-articles-via-membership feature: posts.is_premium, memberships, and billing_webhook_events tables plus MembershipRepository and BillingWebhookEventRepository. The generated migration diff also caught pre-existing drift between the 0023 snapshot and the actually-applied schema (ai_agent_conversations session_id rewrite, comments owner-reply/country columns, companion tables) - those statements were stripped from the emitted SQL since they were already applied; only the new membership/billing DDL is included. The 0024 snapshot itself is accurate and now reflects true current state, fixing the diff baseline forward for future migrations. --- .../0024_membership_billing_tables.sql | 33 + .../migrations/meta/0024_snapshot.json | 6140 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../billing-webhook-event.repository.ts | 87 + .../membership/membership.repository.ts | 136 + .../modules/membership/membership.types.ts | 31 + .../processors/database/repository.tokens.ts | 2 + .../membership.repository.pg.e2e.spec.ts | 170 + packages/db-schema/src/schema/billing.ts | 48 + packages/db-schema/src/schema/content.ts | 1 + packages/db-schema/src/schema/index.ts | 1 + 11 files changed, 6656 insertions(+) create mode 100644 apps/core/src/database/migrations/0024_membership_billing_tables.sql create mode 100644 apps/core/src/database/migrations/meta/0024_snapshot.json create mode 100644 apps/core/src/modules/membership/billing-webhook-event.repository.ts create mode 100644 apps/core/src/modules/membership/membership.repository.ts create mode 100644 apps/core/src/modules/membership/membership.types.ts create mode 100644 apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts create mode 100644 packages/db-schema/src/schema/billing.ts diff --git a/apps/core/src/database/migrations/0024_membership_billing_tables.sql b/apps/core/src/database/migrations/0024_membership_billing_tables.sql new file mode 100644 index 00000000000..064ca37673b --- /dev/null +++ b/apps/core/src/database/migrations/0024_membership_billing_tables.sql @@ -0,0 +1,33 @@ +-- migration-lint:allow=no-bare-create-index reason=indexes and FK target brand-new empty membership/billing tables; CONCURRENTLY cannot run inside the migration transaction +CREATE TABLE "billing_webhook_events" ( + "id" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "event_id" text NOT NULL, + "type" text NOT NULL, + "payload" jsonb NOT NULL, + "processed_at" timestamp with time zone, + "received_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "memberships" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + "reader_id" text NOT NULL, + "provider" text NOT NULL, + "provider_customer_id" text, + "provider_subscription_id" text, + "plan" text NOT NULL, + "status" text NOT NULL, + "current_period_end" timestamp with time zone NOT NULL +); +--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "is_premium" boolean DEFAULT false NOT NULL; +--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_reader_id_readers_id_fk" FOREIGN KEY ("reader_id") REFERENCES "public"."readers"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE UNIQUE INDEX "billing_webhook_events_provider_event_id_uniq" ON "billing_webhook_events" USING btree ("provider","event_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX "memberships_reader_id_uniq" ON "memberships" USING btree ("reader_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX "memberships_provider_subscription_id_uniq" ON "memberships" USING btree ("provider_subscription_id") WHERE "memberships"."provider_subscription_id" is not null; diff --git a/apps/core/src/database/migrations/meta/0024_snapshot.json b/apps/core/src/database/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000000..080b2b0cea7 --- /dev/null +++ b/apps/core/src/database/migrations/meta/0024_snapshot.json @@ -0,0 +1,6140 @@ +{ + "id": "e00bc527-84b9-4dd8-9169-1a82a9f1e712", + "prevId": "0495006c-1907-4ad9-8a66-149f0dc44bc6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_agent_conversations": { + "name": "ai_agent_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_agent_conversation_session_idx": { + "name": "ai_agent_conversation_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_insights": { + "name": "ai_insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_translation": { + "name": "is_translation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_insights_id": { + "name": "source_insights_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_info": { + "name": "model_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_insights_ref_lang_uniq": { + "name": "ai_insights_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_insights_source_insights_id_ai_insights_id_fk": { + "name": "ai_insights_source_insights_id_ai_insights_id_fk", + "tableFrom": "ai_insights", + "tableTo": "ai_insights", + "columnsFrom": [ + "source_insights_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_summaries": { + "name": "ai_summaries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_summaries_ref_id_idx": { + "name": "ai_summaries_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_translations": { + "name": "ai_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_provider": { + "name": "ai_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_block_snapshots": { + "name": "source_block_snapshots", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_meta_hashes": { + "name": "source_meta_hashes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_translations_ref_lang_uniq": { + "name": "ai_translations_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_translations_ref_id_idx": { + "name": "ai_translations_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translation_entries": { + "name": "translation_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "key_path": { + "name": "key_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lookup_key": { + "name": "lookup_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_text": { + "name": "source_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "translated_text": { + "name": "translated_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_updated_at": { + "name": "source_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "translation_entries_key_uniq": { + "name": "translation_entries_key_uniq", + "columns": [ + { + "expression": "key_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lookup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "translation_entries_path_lang_idx": { + "name": "translation_entries_path_lang_idx", + "columns": [ + { + "expression": "key_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "translation_entries_lookup_key_idx": { + "name": "translation_entries_lookup_key_idx", + "columns": [ + { + "expression": "lookup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_uniq": { + "name": "accounts_provider_uniq", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_readers_id_fk": { + "name": "accounts_user_id_readers_id_fk", + "tableFrom": "accounts", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_uniq": { + "name": "api_keys_key_uniq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_readers_id_fk": { + "name": "api_keys_user_id_readers_id_fk", + "tableFrom": "api_keys", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_reference_id_readers_id_fk": { + "name": "api_keys_reference_id_readers_id_fk", + "tableFrom": "api_keys", + "tableTo": "readers", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_codes": { + "name": "device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_codes_device_code_uniq": { + "name": "device_codes_device_code_uniq", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_codes_user_code_uniq": { + "name": "device_codes_user_code_uniq", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_codes_expires_at_idx": { + "name": "device_codes_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_codes_user_id_readers_id_fk": { + "name": "device_codes_user_id_readers_id_fk", + "tableFrom": "device_codes", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_profiles": { + "name": "owner_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mail": { + "name": "mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "introduce": { + "name": "introduce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_ip": { + "name": "last_login_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_time": { + "name": "last_login_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "social_ids": { + "name": "social_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "owner_profiles_reader_id_uniq": { + "name": "owner_profiles_reader_id_uniq", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_profiles_reader_id_readers_id_fk": { + "name": "owner_profiles_reader_id_readers_id_fk", + "tableFrom": "owner_profiles", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkeys_credential_id_uniq": { + "name": "passkeys_credential_id_uniq", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_readers_id_fk": { + "name": "passkeys_user_id_readers_id_fk", + "tableFrom": "passkeys", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.readers": { + "name": "readers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reader'" + }, + "banned_at": { + "name": "banned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "readers_email_uniq": { + "name": "readers_email_uniq", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"readers\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "readers_username_uniq": { + "name": "readers_username_uniq", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"readers\".\"username\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "readers_role_idx": { + "name": "readers_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_token_uniq": { + "name": "sessions_token_uniq", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_readers_id_fk": { + "name": "sessions_user_id_readers_id_fk", + "tableFrom": "sessions", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_provider_event_id_uniq": { + "name": "billing_webhook_events_provider_event_id_uniq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "memberships_reader_id_uniq": { + "name": "memberships_reader_id_uniq", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_provider_subscription_id_uniq": { + "name": "memberships_provider_subscription_id_uniq", + "columns": [ + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"memberships\".\"provider_subscription_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_reader_id_readers_id_fk": { + "name": "memberships_reader_id_readers_id_fk", + "tableFrom": "memberships", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companion_devices": { + "name": "companion_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "presence_cleared_at": { + "name": "presence_cleared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "companion_devices_token_hash_uniq": { + "name": "companion_devices_token_hash_uniq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_devices_owner_created_idx": { + "name": "companion_devices_owner_created_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_devices_pending_presence_clear_idx": { + "name": "companion_devices_pending_presence_clear_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"companion_devices\".\"revoked_at\" is not null and \"companion_devices\".\"presence_cleared_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "companion_devices_owner_id_readers_id_fk": { + "name": "companion_devices_owner_id_readers_id_fk", + "tableFrom": "companion_devices", + "tableTo": "readers", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "companion_devices_token_hash_hex_check": { + "name": "companion_devices_token_hash_hex_check", + "value": "\"companion_devices\".\"token_hash\" ~ '^[0-9a-f]{64}$'" + }, + "companion_devices_scopes_array_check": { + "name": "companion_devices_scopes_array_check", + "value": "jsonb_typeof(\"companion_devices\".\"scopes\") = 'array'" + } + }, + "isRLSEnabled": false + }, + "public.companion_pairings": { + "name": "companion_pairings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "companion_pairings_code_hash_uniq": { + "name": "companion_pairings_code_hash_uniq", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_pairings_owner_created_idx": { + "name": "companion_pairings_owner_created_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_pairings_expires_at_idx": { + "name": "companion_pairings_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "companion_pairings_owner_id_readers_id_fk": { + "name": "companion_pairings_owner_id_readers_id_fk", + "tableFrom": "companion_pairings", + "tableTo": "readers", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "companion_pairings_code_hash_hex_check": { + "name": "companion_pairings_code_hash_hex_check", + "value": "\"companion_pairings\".\"code_hash\" ~ '^[0-9a-f]{64}$'" + }, + "companion_pairings_scopes_array_check": { + "name": "companion_pairings_scopes_array_check", + "value": "jsonb_typeof(\"companion_pairings\".\"scopes\") = 'array'" + }, + "companion_pairings_expiry_check": { + "name": "companion_pairings_expiry_check", + "value": "\"companion_pairings\".\"expires_at\" > \"companion_pairings\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "categories_name_uniq": { + "name": "categories_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_slug_uniq": { + "name": "categories_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mail": { + "name": "mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_comment_id": { + "name": "root_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "latest_reply_at": { + "name": "latest_reply_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_whispers": { + "name": "is_whispers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_owner_reply": { + "name": "is_owner_reply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comments_thread_idx": { + "name": "comments_thread_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_root_idx": { + "name": "comments_root_idx", + "columns": [ + { + "expression": "root_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_reader_idx": { + "name": "comments_reader_idx", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_parent_comment_id_comments_id_fk": { + "name": "comments_parent_comment_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_root_comment_id_comments_id_fk": { + "name": "comments_root_comment_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "root_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_reader_id_readers_id_fk": { + "name": "comments_reader_id_readers_id_fk", + "tableFrom": "comments", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_histories": { + "name": "draft_histories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "draft_id": { + "name": "draft_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type_specific_data": { + "name": "type_specific_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_full_snapshot": { + "name": "is_full_snapshot", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "ref_version": { + "name": "ref_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "draft_histories_draft_version_uniq": { + "name": "draft_histories_draft_version_uniq", + "columns": [ + { + "expression": "draft_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "draft_histories_draft_id_drafts_id_fk": { + "name": "draft_histories_draft_id_drafts_id_fk", + "tableFrom": "draft_histories", + "tableTo": "drafts", + "columnsFrom": [ + "draft_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drafts": { + "name": "drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type_specific_data": { + "name": "type_specific_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "published_version": { + "name": "published_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drafts_ref_idx": { + "name": "drafts_ref_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"drafts\".\"ref_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "drafts_updated_at_idx": { + "name": "drafts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "nid": { + "name": "nid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "notes_nid_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_at": { + "name": "public_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weather": { + "name": "weather", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bookmark": { + "name": "bookmark", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "coordinates": { + "name": "coordinates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notes_nid_uniq": { + "name": "notes_nid_uniq", + "columns": [ + { + "expression": "nid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_slug_uniq": { + "name": "notes_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notes\".\"slug\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_nid_desc_idx": { + "name": "notes_nid_desc_idx", + "columns": [ + { + "expression": "nid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_modified_at_idx": { + "name": "notes_modified_at_idx", + "columns": [ + { + "expression": "modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_created_at_idx": { + "name": "notes_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_topic_id_idx": { + "name": "notes_topic_id_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_published_public_created_idx": { + "name": "notes_published_public_created_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "public_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_topic_id_topics_id_fk": { + "name": "notes_topic_id_topics_id_fk", + "tableFrom": "notes", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pages_slug_uniq": { + "name": "pages_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_order_idx": { + "name": "pages_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_related_posts": { + "name": "post_related_posts", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "related_post_id": { + "name": "related_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "post_related_posts_pk": { + "name": "post_related_posts_pk", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_related_posts_related_idx": { + "name": "post_related_posts_related_idx", + "columns": [ + { + "expression": "related_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_related_posts_post_id_posts_id_fk": { + "name": "post_related_posts_post_id_posts_id_fk", + "tableFrom": "post_related_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_related_posts_related_post_id_posts_id_fk": { + "name": "post_related_posts_related_post_id_posts_id_fk", + "tableFrom": "post_related_posts", + "tableTo": "posts", + "columnsFrom": [ + "related_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "copyright": { + "name": "copyright", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_premium": { + "name": "is_premium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pin_at": { + "name": "pin_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pin_order": { + "name": "pin_order", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_slug_uniq": { + "name": "posts_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_modified_at_idx": { + "name": "posts_modified_at_idx", + "columns": [ + { + "expression": "modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_category_id_idx": { + "name": "posts_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_published_created_at_idx": { + "name": "posts_published_created_at_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "posts_category_published_created_idx": { + "name": "posts_category_published_created_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "posts_tags_gin_idx": { + "name": "posts_tags_gin_idx", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "posts_category_id_categories_id_fk": { + "name": "posts_category_id_categories_id_fk", + "tableFrom": "posts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recentlies": { + "name": "recentlies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comments_index": { + "name": "comments_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_comment": { + "name": "allow_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "up": { + "name": "up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "down": { + "name": "down", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "recentlies_ref_idx": { + "name": "recentlies_ref_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recentlies_created_at_idx": { + "name": "recentlies_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "introduce": { + "name": "introduce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "topics_name_uniq": { + "name": "topics_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_slug_uniq": { + "name": "topics_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_cache": { + "name": "enrichment_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "normalized": { + "name": "normalized", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrichment_provider_external_id_locale_uniq": { + "name": "enrichment_provider_external_id_locale_uniq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrichment_expires_at_idx": { + "name": "enrichment_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_captures": { + "name": "enrichment_captures", + "schema": "", + "columns": { + "enrichment_id": { + "name": "enrichment_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "thumbhash": { + "name": "thumbhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "palette": { + "name": "palette", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrichment_captures_lru_idx": { + "name": "enrichment_captures_lru_idx", + "columns": [ + { + "expression": "last_accessed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_captures_enrichment_id_enrichment_cache_id_fk": { + "name": "enrichment_captures_enrichment_id_enrichment_cache_id_fk", + "tableFrom": "enrichment_captures", + "tableTo": "enrichment_cache", + "columnsFrom": [ + "enrichment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._app_migrations": { + "name": "_app_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_id_map": { + "name": "auth_id_map", + "schema": "", + "columns": { + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mongo_id": { + "name": "mongo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pg_id": { + "name": "pg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_id_map_collection_mongo_uniq": { + "name": "auth_id_map_collection_mongo_uniq", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mongo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_id_map_collection_pg_uniq": { + "name": "auth_id_map_collection_pg_uniq", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_migration_runs": { + "name": "data_migration_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mongo_id_map": { + "name": "mongo_id_map", + "schema": "", + "columns": { + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mongo_id": { + "name": "mongo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snowflake_id": { + "name": "snowflake_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mongo_id_map_pk": { + "name": "mongo_id_map_pk", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mongo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mongo_id_map_snowflake_uniq": { + "name": "mongo_id_map_snowflake_uniq", + "columns": [ + { + "expression": "snowflake_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_migrations": { + "name": "schema_migrations", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activities": { + "name": "activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activities_created_at_idx": { + "name": "activities_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analyzes": { + "name": "analyzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ua": { + "name": "ua", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referer": { + "name": "referer", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "analyzes_timestamp_idx": { + "name": "analyzes_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_path_idx": { + "name": "analyzes_timestamp_path_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_referer_idx": { + "name": "analyzes_timestamp_referer_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "referer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_ip_idx": { + "name": "analyzes_timestamp_ip_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_references": { + "name": "file_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_object_key": { + "name": "s3_object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_references_file_url_idx": { + "name": "file_references_file_url_idx", + "columns": [ + { + "expression": "file_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_ref_idx": { + "name": "file_references_ref_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_status_created_idx": { + "name": "file_references_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_reader_status_created_idx": { + "name": "file_references_reader_status_created_idx", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_status_detached_idx": { + "name": "file_references_status_detached_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_references_reader_id_readers_id_fk": { + "name": "file_references_reader_id_readers_id_fk", + "tableFrom": "file_references", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.links": { + "name": "links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "links_name_uniq": { + "name": "links_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "links_url_uniq": { + "name": "links_url_uniq", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meta_presets": { + "name": "meta_presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "meta_presets_name_uniq": { + "name": "meta_presets_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options": { + "name": "options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "options_name_uniq": { + "name": "options_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_vote_options": { + "name": "poll_vote_options", + "schema": "", + "columns": { + "vote_id": { + "name": "vote_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "option_id": { + "name": "option_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "poll_vote_options_pk": { + "name": "poll_vote_options_pk", + "columns": [ + { + "expression": "vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "poll_vote_options_option_idx": { + "name": "poll_vote_options_option_idx", + "columns": [ + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_vote_options_vote_id_poll_votes_id_fk": { + "name": "poll_vote_options_vote_id_poll_votes_id_fk", + "tableFrom": "poll_vote_options", + "tableTo": "poll_votes", + "columnsFrom": [ + "vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_votes": { + "name": "poll_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "poll_id": { + "name": "poll_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_fingerprint": { + "name": "voter_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "poll_votes_poll_voter_uniq": { + "name": "poll_votes_poll_voter_uniq", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "voter_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "poll_votes_poll_id_idx": { + "name": "poll_votes_poll_id_idx", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "doc_url": { + "name": "doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_url": { + "name": "project_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_name_uniq": { + "name": "projects_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.says": { + "name": "says", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "says_created_at_idx": { + "name": "says_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverless_logs": { + "name": "serverless_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "function_id": { + "name": "function_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_time": { + "name": "execution_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "serverless_logs_created_at_idx": { + "name": "serverless_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "serverless_logs_function_idx": { + "name": "serverless_logs_function_idx", + "columns": [ + { + "expression": "function_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "serverless_logs_reference_idx": { + "name": "serverless_logs_reference_idx", + "columns": [ + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverless_storages": { + "name": "serverless_storages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "serverless_storages_ns_key_uniq": { + "name": "serverless_storages_ns_key_uniq", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slug_trackers": { + "name": "slug_trackers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug_trackers_type_target_idx": { + "name": "slug_trackers_type_target_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slug_trackers_slug_type_idx": { + "name": "slug_trackers_slug_type_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metatype": { + "name": "metatype", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable": { + "name": "enable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "built_in": { + "name": "built_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "compiled_code": { + "name": "compiled_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "snippets_path_prefix_idx": { + "name": "snippets_path_prefix_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_type_idx": { + "name": "snippets_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_path_idx": { + "name": "snippets_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"snippets\".\"method\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_path_method_idx": { + "name": "snippets_path_method_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "method", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"snippets\".\"method\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscribes": { + "name": "subscribes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscribe": { + "name": "subscribe", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "subscribes_email_uniq": { + "name": "subscribes_email_uniq", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscribes_cancel_token_uniq": { + "name": "subscribes_cancel_token_uniq", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hook_id": { + "name": "hook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "webhook_events_hook_id_idx": { + "name": "webhook_events_hook_id_idx", + "columns": [ + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_timestamp_idx": { + "name": "webhook_events_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_hook_id_webhooks_id_fk": { + "name": "webhook_events_hook_id_webhooks_id_fk", + "tableFrom": "webhook_events", + "tableTo": "webhooks", + "columnsFrom": [ + "hook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_url": { + "name": "payload_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhooks_enabled_idx": { + "name": "webhooks_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_documents": { + "name": "search_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "title_term_freq": { + "name": "title_term_freq", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "body_term_freq": { + "name": "body_term_freq", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "title_length": { + "name": "title_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "body_length": { + "name": "body_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nid": { + "name": "nid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_at": { + "name": "public_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_password": { + "name": "has_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_documents_ref_lang_uniq": { + "name": "search_documents_ref_lang_uniq", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_documents_published_idx": { + "name": "search_documents_published_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "public_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_documents_lang_idx": { + "name": "search_documents_lang_idx", + "columns": [ + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/core/src/database/migrations/meta/_journal.json b/apps/core/src/database/migrations/meta/_journal.json index 0c62d8128ec..44ca0f88bd1 100644 --- a/apps/core/src/database/migrations/meta/_journal.json +++ b/apps/core/src/database/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1784178270027, "tag": "0023_companion_devices_pairings", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1784371077025, + "tag": "0024_membership_billing_tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/core/src/modules/membership/billing-webhook-event.repository.ts b/apps/core/src/modules/membership/billing-webhook-event.repository.ts new file mode 100644 index 00000000000..ec7bc2ad37f --- /dev/null +++ b/apps/core/src/modules/membership/billing-webhook-event.repository.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable } from '@nestjs/common' +import { and, eq } from 'drizzle-orm' + +import { PG_DB_TOKEN } from '~/constants/system.constant' +import { billingWebhookEvents } from '~/database/schema' +import { + BaseRepository, + toEntityId, +} from '~/processors/database/base.repository' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { type EntityId, parseEntityId } from '~/shared/id/entity-id' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +import type { BillingWebhookEventRow } from './membership.types' + +const mapRow = ( + row: typeof billingWebhookEvents.$inferSelect, +): BillingWebhookEventRow => ({ + id: toEntityId(row.id) as EntityId, + provider: row.provider, + eventId: row.eventId, + type: row.type, + payload: row.payload, + processedAt: row.processedAt, + receivedAt: row.receivedAt, +}) + +@Injectable() +export class BillingWebhookEventRepository extends BaseRepository { + constructor( + @Inject(PG_DB_TOKEN) db: AppDatabase, + private readonly snowflake: SnowflakeService, + ) { + super(db) + } + + async create(input: { + provider: string + eventId: string + type: string + payload: unknown + receivedAt?: Date + }): Promise { + const id = this.snowflake.nextId() + const [row] = await this.db + .insert(billingWebhookEvents) + .values({ + id, + provider: input.provider, + eventId: input.eventId, + type: input.type, + payload: input.payload as Record, + receivedAt: input.receivedAt ?? new Date(), + }) + .returning() + return mapRow(row) + } + + async findByProviderAndEventId( + provider: string, + eventId: string, + ): Promise { + const [row] = await this.db + .select() + .from(billingWebhookEvents) + .where( + and( + eq(billingWebhookEvents.provider, provider), + eq(billingWebhookEvents.eventId, eventId), + )!, + ) + .limit(1) + return row ? mapRow(row) : null + } + + async markProcessed( + id: EntityId | string, + processedAt: Date, + ): Promise { + const [row] = await this.db + .update(billingWebhookEvents) + .set({ processedAt }) + .where(eq(billingWebhookEvents.id, parseEntityId(id))) + .returning() + return row ? mapRow(row) : null + } +} diff --git a/apps/core/src/modules/membership/membership.repository.ts b/apps/core/src/modules/membership/membership.repository.ts new file mode 100644 index 00000000000..e32a265428e --- /dev/null +++ b/apps/core/src/modules/membership/membership.repository.ts @@ -0,0 +1,136 @@ +import { Inject, Injectable } from '@nestjs/common' +import { eq } from 'drizzle-orm' + +import { PG_DB_TOKEN } from '~/constants/system.constant' +import { memberships } from '~/database/schema' +import { + BaseRepository, + toEntityId, +} from '~/processors/database/base.repository' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { type EntityId, parseEntityId } from '~/shared/id/entity-id' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +import type { + MembershipPlan, + MembershipProvider, + MembershipRow, + MembershipStatus, +} from './membership.types' + +const mapRow = (row: typeof memberships.$inferSelect): MembershipRow => ({ + id: toEntityId(row.id) as EntityId, + readerId: toEntityId(row.readerId) as EntityId, + provider: row.provider as MembershipProvider, + providerCustomerId: row.providerCustomerId, + providerSubscriptionId: row.providerSubscriptionId, + plan: row.plan as MembershipPlan, + status: row.status as MembershipStatus, + currentPeriodEnd: row.currentPeriodEnd, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}) + +@Injectable() +export class MembershipRepository extends BaseRepository { + constructor( + @Inject(PG_DB_TOKEN) db: AppDatabase, + private readonly snowflake: SnowflakeService, + ) { + super(db) + } + + async create(input: { + readerId: EntityId | string + provider: MembershipProvider + providerCustomerId?: string | null + providerSubscriptionId?: string | null + plan: MembershipPlan + status: MembershipStatus + currentPeriodEnd: Date + }): Promise { + const id = this.snowflake.nextId() + const [row] = await this.db + .insert(memberships) + .values({ + id, + readerId: parseEntityId(input.readerId), + provider: input.provider, + providerCustomerId: input.providerCustomerId ?? null, + providerSubscriptionId: input.providerSubscriptionId ?? null, + plan: input.plan, + status: input.status, + currentPeriodEnd: input.currentPeriodEnd, + }) + .returning() + return mapRow(row) + } + + async findById(id: EntityId | string): Promise { + const [row] = await this.db + .select() + .from(memberships) + .where(eq(memberships.id, parseEntityId(id))) + .limit(1) + return row ? mapRow(row) : null + } + + async findByReaderId( + readerId: EntityId | string, + ): Promise { + const [row] = await this.db + .select() + .from(memberships) + .where(eq(memberships.readerId, parseEntityId(readerId))) + .limit(1) + return row ? mapRow(row) : null + } + + async findByProviderSubscriptionId( + providerSubscriptionId: string, + ): Promise { + const [row] = await this.db + .select() + .from(memberships) + .where(eq(memberships.providerSubscriptionId, providerSubscriptionId)) + .limit(1) + return row ? mapRow(row) : null + } + + async update( + id: EntityId | string, + patch: Partial<{ + providerCustomerId: string | null + providerSubscriptionId: string | null + plan: MembershipPlan + status: MembershipStatus + currentPeriodEnd: Date + }>, + ): Promise { + const update: Partial = { + updatedAt: new Date(), + } + if (patch.providerCustomerId !== undefined) + update.providerCustomerId = patch.providerCustomerId + if (patch.providerSubscriptionId !== undefined) + update.providerSubscriptionId = patch.providerSubscriptionId + if (patch.plan !== undefined) update.plan = patch.plan + if (patch.status !== undefined) update.status = patch.status + if (patch.currentPeriodEnd !== undefined) + update.currentPeriodEnd = patch.currentPeriodEnd + const [row] = await this.db + .update(memberships) + .set(update) + .where(eq(memberships.id, parseEntityId(id))) + .returning() + return row ? mapRow(row) : null + } + + async deleteById(id: EntityId | string): Promise { + const [row] = await this.db + .delete(memberships) + .where(eq(memberships.id, parseEntityId(id))) + .returning() + return row ? mapRow(row) : null + } +} diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts new file mode 100644 index 00000000000..65150d65eb2 --- /dev/null +++ b/apps/core/src/modules/membership/membership.types.ts @@ -0,0 +1,31 @@ +import type { EntityId } from '~/shared/id/entity-id' + +export type MembershipProvider = + 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' + +export type MembershipPlan = 'monthly' | 'yearly' + +export type MembershipStatus = 'active' | 'on_hold' | 'cancelled' | 'expired' + +export interface MembershipRow { + id: EntityId + readerId: EntityId + provider: MembershipProvider + providerCustomerId: string | null + providerSubscriptionId: string | null + plan: MembershipPlan + status: MembershipStatus + currentPeriodEnd: Date + createdAt: Date + updatedAt: Date | null +} + +export interface BillingWebhookEventRow { + id: EntityId + provider: string + eventId: string + type: string + payload: unknown + processedAt: Date | null + receivedAt: Date +} diff --git a/apps/core/src/processors/database/repository.tokens.ts b/apps/core/src/processors/database/repository.tokens.ts index 80b5495ce74..93d418a3b3d 100644 --- a/apps/core/src/processors/database/repository.tokens.ts +++ b/apps/core/src/processors/database/repository.tokens.ts @@ -39,6 +39,8 @@ export const POSTGRES_REPOSITORY_TOKENS = { webhookEvent: Symbol('WebhookEventRepository'), options: Symbol('OptionsRepository'), metaPreset: Symbol('MetaPresetRepository'), + membership: Symbol('MembershipRepository'), + billingWebhookEvent: Symbol('BillingWebhookEventRepository'), } as const export type PostgresRepositoryToken = diff --git a/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts b/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts new file mode 100644 index 00000000000..62471663748 --- /dev/null +++ b/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts @@ -0,0 +1,170 @@ +import * as schema from '@mx-space/db-schema/schema' +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { createIsolatedPgDatabase } from 'test/helper/pg-testcontainer' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { BillingWebhookEventRepository } from '~/modules/membership/billing-webhook-event.repository' +import { MembershipRepository } from '~/modules/membership/membership.repository' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +describe('Membership + billing webhook event repositories (real PG)', () => { + let pool: Pool + let database: Awaited> + let membershipRepository: MembershipRepository + let billingWebhookEventRepository: BillingWebhookEventRepository + const snowflake = new SnowflakeService() + const readerId = snowflake.nextId() + const otherReaderId = snowflake.nextId() + + beforeAll(async () => { + database = await createIsolatedPgDatabase() + pool = new Pool({ connectionString: database.getConnectionUri(), max: 4 }) + const db = drizzle(pool, { schema }) as unknown as AppDatabase + membershipRepository = new MembershipRepository(db, snowflake) + billingWebhookEventRepository = new BillingWebhookEventRepository( + db, + snowflake, + ) + await db.insert(schema.readers).values([ + { id: readerId, name: 'Reader One', role: 'reader' }, + { id: otherReaderId, name: 'Reader Two', role: 'reader' }, + ]) + }, 120_000) + + afterAll(async () => { + await pool?.end() + await database?.drop() + }) + + it('creates, reads, updates and deletes a membership', async () => { + const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + const created = await membershipRepository.create({ + readerId, + provider: 'dodo', + providerCustomerId: 'cus_123', + providerSubscriptionId: 'sub_123', + plan: 'monthly', + status: 'active', + currentPeriodEnd, + }) + + expect(created.readerId).toBe(readerId) + expect(created.status).toBe('active') + + await expect(membershipRepository.findById(created.id)).resolves.toEqual( + created, + ) + await expect( + membershipRepository.findByReaderId(readerId), + ).resolves.toEqual(created) + await expect( + membershipRepository.findByProviderSubscriptionId('sub_123'), + ).resolves.toEqual(created) + + const updated = await membershipRepository.update(created.id, { + status: 'on_hold', + }) + expect(updated?.status).toBe('on_hold') + expect(updated?.updatedAt).not.toBeNull() + + const deleted = await membershipRepository.deleteById(created.id) + expect(deleted?.id).toBe(created.id) + await expect(membershipRepository.findById(created.id)).resolves.toBeNull() + }) + + it('rejects a second membership for the same reader (unique reader_id)', async () => { + const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + await membershipRepository.create({ + readerId: otherReaderId, + provider: 'manual', + plan: 'yearly', + status: 'active', + currentPeriodEnd, + }) + + await expect( + membershipRepository.create({ + readerId: otherReaderId, + provider: 'manual', + plan: 'monthly', + status: 'active', + currentPeriodEnd, + }), + ).rejects.toThrow() + }) + + it('rejects a second membership with the same provider_subscription_id', async () => { + const readerA = snowflake.nextId() + const readerB = snowflake.nextId() + const db = drizzle(pool, { schema }) as unknown as AppDatabase + await db.insert(schema.readers).values([ + { id: readerA, name: 'Reader A', role: 'reader' }, + { id: readerB, name: 'Reader B', role: 'reader' }, + ]) + + const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + await membershipRepository.create({ + readerId: readerA, + provider: 'dodo', + providerSubscriptionId: 'sub_dup', + plan: 'monthly', + status: 'active', + currentPeriodEnd, + }) + + await expect( + membershipRepository.create({ + readerId: readerB, + provider: 'dodo', + providerSubscriptionId: 'sub_dup', + plan: 'monthly', + status: 'active', + currentPeriodEnd, + }), + ).rejects.toThrow() + }) + + it('creates and reads billing webhook events, skipping duplicates', async () => { + const created = await billingWebhookEventRepository.create({ + provider: 'dodo', + eventId: 'evt_123', + type: 'subscription.active', + payload: { hello: 'world' }, + }) + + expect(created.processedAt).toBeNull() + + await expect( + billingWebhookEventRepository.findByProviderAndEventId('dodo', 'evt_123'), + ).resolves.toEqual(created) + + const processedAt = new Date() + const processed = await billingWebhookEventRepository.markProcessed( + created.id, + processedAt, + ) + expect(processed?.processedAt?.toISOString()).toBe( + processedAt.toISOString(), + ) + }) + + it('rejects a duplicate (provider, event_id) pair', async () => { + await billingWebhookEventRepository.create({ + provider: 'dodo', + eventId: 'evt_dup', + type: 'subscription.renewed', + payload: {}, + }) + + await expect( + billingWebhookEventRepository.create({ + provider: 'dodo', + eventId: 'evt_dup', + type: 'subscription.renewed', + payload: {}, + }), + ).rejects.toThrow() + }) +}) diff --git a/packages/db-schema/src/schema/billing.ts b/packages/db-schema/src/schema/billing.ts new file mode 100644 index 00000000000..2f625604471 --- /dev/null +++ b/packages/db-schema/src/schema/billing.ts @@ -0,0 +1,48 @@ +import { sql } from 'drizzle-orm' +import { jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core' + +import { readers } from './auth' +import { createdAt, pkText, refText, tsCol, updatedAt } from './columns' + +export const memberships = pgTable( + 'memberships', + { + id: pkText(), + createdAt: createdAt(), + updatedAt: updatedAt(), + readerId: refText('reader_id') + .notNull() + .references(() => readers.id, { onDelete: 'cascade' }), + provider: text('provider').notNull(), + providerCustomerId: text('provider_customer_id'), + providerSubscriptionId: text('provider_subscription_id'), + plan: text('plan').notNull(), + status: text('status').notNull(), + currentPeriodEnd: tsCol('current_period_end').notNull(), + }, + (table) => [ + uniqueIndex('memberships_reader_id_uniq').on(table.readerId), + uniqueIndex('memberships_provider_subscription_id_uniq') + .on(table.providerSubscriptionId) + .where(sql`${table.providerSubscriptionId} is not null`), + ], +) + +export const billingWebhookEvents = pgTable( + 'billing_webhook_events', + { + id: pkText(), + provider: text('provider').notNull(), + eventId: text('event_id').notNull(), + type: text('type').notNull(), + payload: jsonb('payload').$type>().notNull(), + processedAt: tsCol('processed_at'), + receivedAt: createdAt('received_at'), + }, + (table) => [ + uniqueIndex('billing_webhook_events_provider_event_id_uniq').on( + table.provider, + table.eventId, + ), + ], +) diff --git a/packages/db-schema/src/schema/content.ts b/packages/db-schema/src/schema/content.ts index 17c3730e115..d1d463cd021 100644 --- a/packages/db-schema/src/schema/content.ts +++ b/packages/db-schema/src/schema/content.ts @@ -68,6 +68,7 @@ export const posts = pgTable( .references(() => categories.id, { onDelete: 'restrict' }), copyright: boolean('copyright').notNull().default(true), isPublished: boolean('is_published').notNull().default(true), + isPremium: boolean('is_premium').notNull().default(false), readCount: integer('read_count').notNull().default(0), likeCount: integer('like_count').notNull().default(0), pinAt: tsCol('pin_at'), diff --git a/packages/db-schema/src/schema/index.ts b/packages/db-schema/src/schema/index.ts index 4afc359e856..2027b4a7369 100644 --- a/packages/db-schema/src/schema/index.ts +++ b/packages/db-schema/src/schema/index.ts @@ -1,5 +1,6 @@ export * from './ai' export * from './auth' +export * from './billing' export * from './companion' export * from './content' export * from './enrichment' From d580d23508165a776dc9d17a6972de3cf35a392a Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 18:48:58 +0800 Subject: [PATCH 02/37] feat(post): add pure Lexical truncation helpers for paywall teasers Add truncateLexicalContent and renderTeaserText, mirroring the createHeadlessEditor + allHeadlessNodes + $toMarkdown pattern used by LexicalService via the shared mxLexicalToMarkdown projection. --- .../helper/lexical-truncate.util.ts | 43 +++++++ .../helper/lexical-truncate.util.spec.ts | 114 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 apps/core/src/processors/helper/lexical-truncate.util.ts create mode 100644 apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts diff --git a/apps/core/src/processors/helper/lexical-truncate.util.ts b/apps/core/src/processors/helper/lexical-truncate.util.ts new file mode 100644 index 00000000000..5a4c5a61ae4 --- /dev/null +++ b/apps/core/src/processors/helper/lexical-truncate.util.ts @@ -0,0 +1,43 @@ +import { mxLexicalToMarkdown } from '@mx-space/editor' + +interface LexicalEditorState { + root: { + children: unknown[] + [key: string]: unknown + } + [key: string]: unknown +} + +function parseLexicalState(contentJson: string): LexicalEditorState { + const parsed = JSON.parse(contentJson) + if (!parsed?.root || !Array.isArray(parsed.root.children)) { + throw new Error('Invalid Lexical editor state: missing root.children') + } + return parsed +} + +export function truncateLexicalContent( + contentJson: string, + nBlocks: number, +): string { + const state = parseLexicalState(contentJson) + + if (nBlocks >= state.root.children.length) { + return contentJson + } + + const truncated: LexicalEditorState = { + ...state, + root: { + ...state.root, + children: state.root.children.slice(0, Math.max(nBlocks, 0)), + }, + } + + return JSON.stringify(truncated) +} + +export function renderTeaserText(truncatedJson: string): string { + parseLexicalState(truncatedJson) + return mxLexicalToMarkdown(truncatedJson) +} diff --git a/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts b/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts new file mode 100644 index 00000000000..f22091f161a --- /dev/null +++ b/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { + renderTeaserText, + truncateLexicalContent, +} from '~/processors/helper/lexical-truncate.util' + +function makeEditorState(children: any[]) { + return JSON.stringify({ + root: { + children, + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }) +} + +function textNode(text: string) { + return { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text, + type: 'text', + version: 1, + } +} + +function paragraph(...children: any[]) { + return { + children, + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + } +} + +describe('truncateLexicalContent', () => { + it('keeps exactly N top-level blocks', () => { + const content = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + paragraph(textNode('three')), + ]) + + const truncated = truncateLexicalContent(content, 2) + const parsed = JSON.parse(truncated) + + expect(parsed.root.children).toHaveLength(2) + expect(parsed.root.children[0].children[0].text).toBe('one') + expect(parsed.root.children[1].children[0].text).toBe('two') + }) + + it('returns content unchanged when N >= block count', () => { + const content = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + ]) + + expect(truncateLexicalContent(content, 2)).toBe(content) + expect(truncateLexicalContent(content, 5)).toBe(content) + }) + + it('throws on invalid JSON', () => { + expect(() => truncateLexicalContent('not json', 2)).toThrow() + }) + + it('throws when root.children is missing', () => { + expect(() => truncateLexicalContent('{"root":{}}', 2)).toThrow() + }) + + it('truncates to zero blocks when N is 0', () => { + const content = makeEditorState([paragraph(textNode('one'))]) + + const truncated = truncateLexicalContent(content, 0) + const parsed = JSON.parse(truncated) + + expect(parsed.root.children).toHaveLength(0) + }) +}) + +describe('renderTeaserText', () => { + it('equals $toMarkdown of the truncated state', () => { + const content = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + paragraph(textNode('three')), + ]) + + const truncated = truncateLexicalContent(content, 2) + const teaser = renderTeaserText(truncated) + + expect(teaser).toBe('one\n\ntwo') + }) + + it('returns empty string for a zero-block state', () => { + const truncated = truncateLexicalContent( + makeEditorState([paragraph(textNode('one'))]), + 0, + ) + + expect(renderTeaserText(truncated)).toBe('') + }) + + it('throws on invalid JSON', () => { + expect(() => renderTeaserText('not json')).toThrow() + }) +}) From 49e147a5ee0a0d61465ee4651af8adf7ed9ad9f0 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 19:04:36 +0800 Subject: [PATCH 03/37] feat(membership): add membership services, Dodo provider adapter, config, error codes Adds the core membership module: PaymentProviderAdapter interface and a DodoProvider implementation (dodopayments + standardwebhooks, imported only inside providers/), MembershipService.applyEvent with idempotent webhook processing and manual grant/revoke, and EntitlementService.isActiveMember as the single entitlement entry point for other modules. Wires a `membership` configs section (enabled/provider/product ids) and DODO_API_KEY/DODO_WEBHOOK_KEY env secrets, plus MembershipRequired/WebhookVerifyFailed/ MembershipProviderNotConfigured/PremiumRequiresLexical error codes. No HTTP surface yet (controllers land in a follow-up task). --- apps/core/package.json | 2 + apps/core/src/app.config.test.ts | 5 + apps/core/src/app.config.ts | 9 +- apps/core/src/app.module.ts | 2 + apps/core/src/common/errors/app-error-code.ts | 6 + .../common/errors/app-error-definitions.ts | 21 +- .../src/common/errors/app-error-payload.ts | 6 + .../src/modules/configs/configs.default.ts | 6 + .../src/modules/configs/configs.interface.ts | 2 + .../src/modules/configs/configs.schema.ts | 26 ++ .../modules/membership/entitlement.service.ts | 18 + .../modules/membership/membership.module.ts | 24 ++ .../membership/membership.repository.ts | 2 + .../modules/membership/membership.service.ts | 149 ++++++++ .../membership/providers/dodo.provider.ts | 137 +++++++ .../providers/provider.interface.ts | 26 ++ apps/core/test/setup-global.ts | 2 + .../modules/membership/dodo.provider.spec.ts | 197 ++++++++++ .../membership/entitlement.service.spec.ts | 100 +++++ .../membership/membership.service.spec.ts | 344 ++++++++++++++++++ pnpm-lock.yaml | 30 +- 21 files changed, 1101 insertions(+), 13 deletions(-) create mode 100644 apps/core/src/modules/membership/entitlement.service.ts create mode 100644 apps/core/src/modules/membership/membership.module.ts create mode 100644 apps/core/src/modules/membership/membership.service.ts create mode 100644 apps/core/src/modules/membership/providers/dodo.provider.ts create mode 100644 apps/core/src/modules/membership/providers/provider.interface.ts create mode 100644 apps/core/test/src/modules/membership/dodo.provider.spec.ts create mode 100644 apps/core/test/src/modules/membership/entitlement.service.spec.ts create mode 100644 apps/core/test/src/modules/membership/membership.service.spec.ts diff --git a/apps/core/package.json b/apps/core/package.json index 75dac1f6bca..7019ae19586 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -98,6 +98,7 @@ "commander": "15.0.0", "dayjs": "1.11.21", "diff-match-patch": "^1.0.5", + "dodopayments": "^2.42.2", "dotenv": "^17.4.2", "dotenv-expand": "^13.0.0", "drizzle-orm": "^0.45.2", @@ -141,6 +142,7 @@ "semver": "7.8.5", "slugify": "1.6.9", "source-map-support": "^0.5.21", + "standardwebhooks": "^1.0.0", "thumbhash": "0.1.1", "typebox": "1.3.6", "ua-parser-js": "2.0.10", diff --git a/apps/core/src/app.config.test.ts b/apps/core/src/app.config.test.ts index a511fc20907..2911fcf9a9c 100644 --- a/apps/core/src/app.config.test.ts +++ b/apps/core/src/app.config.test.ts @@ -59,6 +59,11 @@ export const SNOWFLAKE = { epochMs: 1746144000000, } +export const MEMBERSHIP = { + dodoApiKey: process.env.DODO_API_KEY, + dodoWebhookKey: process.env.DODO_WEBHOOK_KEY, +} + export const POSTGRES = { connectionString: process.env.PG_URL || process.env.PG_CONNECTION_STRING, host: process.env.PG_HOST || '127.0.0.1', diff --git a/apps/core/src/app.config.ts b/apps/core/src/app.config.ts index ee9e933a4f0..23bba1a2708 100644 --- a/apps/core/src/app.config.ts +++ b/apps/core/src/app.config.ts @@ -268,9 +268,7 @@ export const HTTP_CACHE = { enableCDNHeader: parseBooleanishValue( (argv.http_cache_enable_cdn_header ?? CDN_CACHE_HEADER) as unknown as - | string - | boolean - | undefined, + string | boolean | undefined, ) ?? true, // s-maxage enableForceCacheHeader: parseBooleanishValue( @@ -388,3 +386,8 @@ export const ADMIN_UPDATE = { process.env.ADMIN_UPDATE_S3_BASE_URL || 'https://admin-r2.innei.dev') as string, } + +export const MEMBERSHIP = { + dodoApiKey: process.env.DODO_API_KEY as string | undefined, + dodoWebhookKey: process.env.DODO_WEBHOOK_KEY as string | undefined, +} diff --git a/apps/core/src/app.module.ts b/apps/core/src/app.module.ts index 0e7f3d18939..ef019a05212 100644 --- a/apps/core/src/app.module.ts +++ b/apps/core/src/app.module.ts @@ -43,6 +43,7 @@ import { HelperModule as BizHelperModule } from './modules/helper/helper.module' import { InitModule } from './modules/init/init.module' import { LinkModule } from './modules/link/link.module' import { MarkdownModule } from './modules/markdown/markdown.module' +import { MembershipModule } from './modules/membership/membership.module' import { MetaPresetModule } from './modules/meta-preset/meta-preset.module' import { NoteModule } from './modules/note/note.module' import { OptionModule } from './modules/option/option.module' @@ -106,6 +107,7 @@ import { SampleResponseInterceptor } from './shared/sample/sample-response.inter HealthModule, LinkModule, MarkdownModule, + MembershipModule, MetaPresetModule, NoteModule, OptionModule, diff --git a/apps/core/src/common/errors/app-error-code.ts b/apps/core/src/common/errors/app-error-code.ts index a9f917c5cd9..cbfb2a43328 100644 --- a/apps/core/src/common/errors/app-error-code.ts +++ b/apps/core/src/common/errors/app-error-code.ts @@ -153,6 +153,12 @@ export enum AppErrorCode { USER_NOT_EXISTS = 'USER_NOT_EXISTS', USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS', + // membership + MEMBERSHIP_REQUIRED = 'MEMBERSHIP_REQUIRED', + WEBHOOK_VERIFY_FAILED = 'WEBHOOK_VERIFY_FAILED', + MEMBERSHIP_PROVIDER_NOT_CONFIGURED = 'MEMBERSHIP_PROVIDER_NOT_CONFIGURED', + PREMIUM_REQUIRES_LEXICAL = 'PREMIUM_REQUIRES_LEXICAL', + // page PAGE_NOT_FOUND = 'PAGE_NOT_FOUND', diff --git a/apps/core/src/common/errors/app-error-definitions.ts b/apps/core/src/common/errors/app-error-definitions.ts index 55dfb1ab00b..8889f59d076 100644 --- a/apps/core/src/common/errors/app-error-definitions.ts +++ b/apps/core/src/common/errors/app-error-definitions.ts @@ -12,8 +12,7 @@ type AppErrorDefinition = ? { status: number message: - | string - | ((payload: PresentPayloadFor | undefined) => string) + string | ((payload: PresentPayloadFor | undefined) => string) details?: ( payload: PresentPayloadFor | undefined, ) => Record | undefined @@ -562,6 +561,24 @@ export const APP_ERROR_DEFINITIONS = { message: 'An owner already exists', }, + // membership + [AppErrorCode.MEMBERSHIP_REQUIRED]: { + status: 403, + message: 'An active membership is required', + }, + [AppErrorCode.WEBHOOK_VERIFY_FAILED]: { + status: 400, + message: 'Webhook signature verification failed', + }, + [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED]: { + status: 400, + message: 'No membership payment provider is configured', + }, + [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: { + status: 400, + message: 'Only Lexical-format posts can be marked as premium', + }, + // page [AppErrorCode.PAGE_NOT_FOUND]: { status: 404, diff --git a/apps/core/src/common/errors/app-error-payload.ts b/apps/core/src/common/errors/app-error-payload.ts index a7dbe9bb0ae..cde22601d7f 100644 --- a/apps/core/src/common/errors/app-error-payload.ts +++ b/apps/core/src/common/errors/app-error-payload.ts @@ -159,6 +159,12 @@ export type AppErrorPayloadMap = { [AppErrorCode.USER_NOT_EXISTS]: undefined [AppErrorCode.USER_ALREADY_EXISTS]: undefined + // membership + [AppErrorCode.MEMBERSHIP_REQUIRED]: undefined + [AppErrorCode.WEBHOOK_VERIFY_FAILED]: undefined + [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED]: undefined + [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: undefined + // page [AppErrorCode.PAGE_NOT_FOUND]: WithId diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 44730e77540..7cf4a8ab8b7 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -167,4 +167,10 @@ export const generateDefaultConfig: () => IConfig = () => ({ secrets: {}, public: {}, }, + membership: { + enabled: false, + provider: 'dodo', + monthlyProductId: '', + yearlyProductId: '', + }, }) diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index 380726ae680..6c66fd75d6d 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -16,6 +16,7 @@ import { type FriendLinkOptionsSchema, type ImageStorageOptionsSchema, type MailOptionsSchema, + type MembershipSchema, type OAuthSchema, type SeoSchema, type ThirdPartyServiceIntegrationSchema, @@ -52,6 +53,7 @@ export abstract class IConfig { authSecurity: z.infer ai: z.infer oauth: z.infer + membership: Required> } export type IConfigKeys = keyof IConfig diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 7088bfc3d3b..32ae1fca8cb 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -920,6 +920,31 @@ export const AISchema = section('AI settings', { export class AIDto extends createZodDto(AISchema) {} export type AIConfig = z.infer +// ==================== Membership ==================== +export const MembershipSchema = section('Membership', { + enabled: field.toggle(z.boolean().optional(), 'Enable paid membership'), + provider: field.select( + z.enum(['dodo', 'creem', 'lemonsqueezy', 'stripe']).optional(), + 'Payment provider', + [ + { label: 'Dodo Payments', value: 'dodo' }, + { label: 'Creem', value: 'creem' }, + { label: 'Lemon Squeezy', value: 'lemonsqueezy' }, + { label: 'Stripe', value: 'stripe' }, + ], + ), + monthlyProductId: field.halfGrid( + z.string().optional(), + 'Monthly plan product ID', + ), + yearlyProductId: field.halfGrid( + z.string().optional(), + 'Yearly plan product ID', + ), +}) +export class MembershipDto extends createZodDto(MembershipSchema) {} +export type MembershipConfig = z.infer + // ==================== OAuth ==================== const OAuthProviderSchema = z.object({ type: z.string().min(1), @@ -961,6 +986,7 @@ export const configSchemaMapping = { authSecurity: AuthSecuritySchema, ai: AISchema, oauth: OAuthSchema, + membership: MembershipSchema, } as const export type ConfigSchemaMapping = typeof configSchemaMapping diff --git a/apps/core/src/modules/membership/entitlement.service.ts b/apps/core/src/modules/membership/entitlement.service.ts new file mode 100644 index 00000000000..69fd4384bef --- /dev/null +++ b/apps/core/src/modules/membership/entitlement.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common' + +import type { EntityId } from '~/shared/id/entity-id' + +import { MembershipRepository } from './membership.repository' + +@Injectable() +export class EntitlementService { + constructor(private readonly membershipRepository: MembershipRepository) {} + + async isActiveMember(readerId: EntityId | string): Promise { + const membership = await this.membershipRepository.findByReaderId(readerId) + if (!membership) return false + if (membership.status !== 'active' && membership.status !== 'on_hold') + return false + return membership.currentPeriodEnd.getTime() > Date.now() + } +} diff --git a/apps/core/src/modules/membership/membership.module.ts b/apps/core/src/modules/membership/membership.module.ts new file mode 100644 index 00000000000..3ca7f1c5b26 --- /dev/null +++ b/apps/core/src/modules/membership/membership.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' + +import { BillingWebhookEventRepository } from './billing-webhook-event.repository' +import { EntitlementService } from './entitlement.service' +import { MembershipRepository } from './membership.repository' +import { MembershipService } from './membership.service' +import { DodoProvider } from './providers/dodo.provider' + +@Module({ + providers: [ + MembershipService, + EntitlementService, + MembershipRepository, + BillingWebhookEventRepository, + DodoProvider, + ], + exports: [ + MembershipService, + EntitlementService, + MembershipRepository, + DodoProvider, + ], +}) +export class MembershipModule {} diff --git a/apps/core/src/modules/membership/membership.repository.ts b/apps/core/src/modules/membership/membership.repository.ts index e32a265428e..e900b699c55 100644 --- a/apps/core/src/modules/membership/membership.repository.ts +++ b/apps/core/src/modules/membership/membership.repository.ts @@ -100,6 +100,7 @@ export class MembershipRepository extends BaseRepository { async update( id: EntityId | string, patch: Partial<{ + provider: MembershipProvider providerCustomerId: string | null providerSubscriptionId: string | null plan: MembershipPlan @@ -110,6 +111,7 @@ export class MembershipRepository extends BaseRepository { const update: Partial = { updatedAt: new Date(), } + if (patch.provider !== undefined) update.provider = patch.provider if (patch.providerCustomerId !== undefined) update.providerCustomerId = patch.providerCustomerId if (patch.providerSubscriptionId !== undefined) diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts new file mode 100644 index 00000000000..2dc603e7bf1 --- /dev/null +++ b/apps/core/src/modules/membership/membership.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common' + +import { AppErrorCode, createAppException } from '~/common/errors' +import type { EntityId } from '~/shared/id/entity-id' + +import { BillingWebhookEventRepository } from './billing-webhook-event.repository' +import { MembershipRepository } from './membership.repository' +import type { MembershipPlan, MembershipRow } from './membership.types' +import type { NormalizedBillingEvent } from './providers/provider.interface' + +const isLiveProviderSubscription = (row: MembershipRow): boolean => { + if (row.provider === 'manual') return false + if (row.status === 'active') return true + return row.status === 'on_hold' && row.currentPeriodEnd.getTime() > Date.now() +} + +@Injectable() +export class MembershipService { + constructor( + private readonly membershipRepository: MembershipRepository, + private readonly billingWebhookEventRepository: BillingWebhookEventRepository, + ) {} + + async getByReaderId( + readerId: EntityId | string, + ): Promise { + return this.membershipRepository.findByReaderId(readerId) + } + + async applyEvent( + event: NormalizedBillingEvent, + ): Promise<{ applied: boolean }> { + const existingWebhookEvent = + await this.billingWebhookEventRepository.findByProviderAndEventId( + event.provider, + event.eventId, + ) + if (existingWebhookEvent) return { applied: false } + + const webhookEventRow = await this.billingWebhookEventRepository.create({ + provider: event.provider, + eventId: event.eventId, + type: event.type, + payload: event, + }) + + await this.applyMembershipState(event) + await this.billingWebhookEventRepository.markProcessed( + webhookEventRow.id, + new Date(), + ) + + return { applied: true } + } + + private async applyMembershipState( + event: NormalizedBillingEvent, + ): Promise { + const existing = + (await this.membershipRepository.findByProviderSubscriptionId( + event.subscriptionId, + )) ?? (await this.membershipRepository.findByReaderId(event.readerId)) + + if (event.type === 'plan_changed') { + if (existing && event.plan) { + await this.membershipRepository.update(existing.id, { + plan: event.plan, + }) + } + return + } + + const status = + event.type === 'cancelled' + ? 'cancelled' + : event.type === 'on_hold' + ? 'on_hold' + : 'active' + + if (existing) { + await this.membershipRepository.update(existing.id, { + provider: event.provider as MembershipRow['provider'], + providerCustomerId: event.customerId, + providerSubscriptionId: event.subscriptionId, + plan: event.plan ?? existing.plan, + status, + currentPeriodEnd: event.currentPeriodEnd, + }) + return + } + + await this.membershipRepository.create({ + readerId: event.readerId, + provider: event.provider as MembershipRow['provider'], + providerCustomerId: event.customerId, + providerSubscriptionId: event.subscriptionId, + plan: event.plan ?? 'monthly', + status, + currentPeriodEnd: event.currentPeriodEnd, + }) + } + + async grantManual( + readerId: EntityId | string, + input: { plan: MembershipPlan; expiresAt: Date }, + ): Promise { + const existing = await this.membershipRepository.findByReaderId(readerId) + if (existing && isLiveProviderSubscription(existing)) { + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: + 'Reader has a live provider-managed subscription; manage it in the provider portal', + }) + } + + if (existing) { + const updated = await this.membershipRepository.update(existing.id, { + provider: 'manual', + providerCustomerId: null, + providerSubscriptionId: null, + plan: input.plan, + status: 'active', + currentPeriodEnd: input.expiresAt, + }) + return updated! + } + + return this.membershipRepository.create({ + readerId, + provider: 'manual', + plan: input.plan, + status: 'active', + currentPeriodEnd: input.expiresAt, + }) + } + + async revokeManual(readerId: EntityId | string): Promise { + const existing = await this.membershipRepository.findByReaderId(readerId) + if (!existing || existing.provider !== 'manual') { + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'No manual grant found for this reader', + }) + } + + const updated = await this.membershipRepository.update(existing.id, { + status: 'cancelled', + }) + return updated! + } +} diff --git a/apps/core/src/modules/membership/providers/dodo.provider.ts b/apps/core/src/modules/membership/providers/dodo.provider.ts new file mode 100644 index 00000000000..c974f55bc14 --- /dev/null +++ b/apps/core/src/modules/membership/providers/dodo.provider.ts @@ -0,0 +1,137 @@ +import { Injectable } from '@nestjs/common' +import DodoPayments from 'dodopayments' +import { Webhook } from 'standardwebhooks' + +import { MEMBERSHIP } from '~/app.config' +import { AppErrorCode, createAppException } from '~/common/errors' + +import { ConfigsService } from '../../configs/configs.service' +import type { MembershipPlan } from '../membership.types' +import type { + NormalizedBillingEvent, + PaymentProviderAdapter, +} from './provider.interface' + +type DodoSubscriptionEvent = { + type: + | 'subscription.active' + | 'subscription.renewed' + | 'subscription.on_hold' + | 'subscription.cancelled' + | 'subscription.expired' + | 'subscription.plan_changed' + business_id: string + timestamp: string + data: { + subscription_id: string + customer: { customer_id: string } + metadata?: Record + next_billing_date: string + payment_frequency_interval?: 'Day' | 'Week' | 'Month' | 'Year' + } +} + +const DODO_EVENT_TYPE_MAP: Record< + string, + NormalizedBillingEvent['type'] | undefined +> = { + 'subscription.active': 'activated', + 'subscription.renewed': 'renewed', + 'subscription.on_hold': 'on_hold', + 'subscription.cancelled': 'cancelled', + 'subscription.expired': 'cancelled', + 'subscription.plan_changed': 'plan_changed', +} + +const planFromInterval = ( + interval: DodoSubscriptionEvent['data']['payment_frequency_interval'], +): MembershipPlan | undefined => { + if (interval === 'Month') return 'monthly' + if (interval === 'Year') return 'yearly' + return undefined +} + +@Injectable() +export class DodoProvider implements PaymentProviderAdapter { + private client: DodoPayments | null = null + + constructor(private readonly configsService: ConfigsService) {} + + private getClient(): DodoPayments { + if (!MEMBERSHIP.dodoApiKey) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + this.client ??= new DodoPayments({ bearerToken: MEMBERSHIP.dodoApiKey }) + return this.client + } + + async createCheckout(input: { + reader: { id: string; email?: string | null; name?: string | null } + plan: MembershipPlan + }): Promise<{ checkoutUrl: string }> { + const membershipConfig = await this.configsService.get('membership') + const productId = + input.plan === 'monthly' + ? membershipConfig.monthlyProductId + : membershipConfig.yearlyProductId + + if (!productId) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + const session = await this.getClient().checkoutSessions.create({ + product_cart: [{ product_id: productId, quantity: 1 }], + metadata: { readerId: input.reader.id }, + customer: input.reader.email + ? { email: input.reader.email, name: input.reader.name ?? undefined } + : undefined, + }) + + if (!session.checkout_url) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + return { checkoutUrl: session.checkout_url } + } + + async verifyAndParseWebhook( + rawBody: Buffer | string, + headers: Record, + ): Promise { + if (!MEMBERSHIP.dodoWebhookKey) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + const payload = + typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8') + const webhook = new Webhook(MEMBERSHIP.dodoWebhookKey) + + let event: DodoSubscriptionEvent + try { + event = webhook.verify(payload, headers) as DodoSubscriptionEvent + } catch { + throw createAppException(AppErrorCode.WEBHOOK_VERIFY_FAILED) + } + + const type = DODO_EVENT_TYPE_MAP[event.type] + if (!type) { + throw createAppException(AppErrorCode.WEBHOOK_VERIFY_FAILED) + } + + const readerId = event.data.metadata?.readerId + if (!readerId) { + throw createAppException(AppErrorCode.WEBHOOK_VERIFY_FAILED) + } + + return { + eventId: headers['webhook-id'], + provider: 'dodo', + type, + customerId: event.data.customer.customer_id, + subscriptionId: event.data.subscription_id, + plan: planFromInterval(event.data.payment_frequency_interval), + currentPeriodEnd: new Date(event.data.next_billing_date), + readerId, + } + } +} diff --git a/apps/core/src/modules/membership/providers/provider.interface.ts b/apps/core/src/modules/membership/providers/provider.interface.ts new file mode 100644 index 00000000000..c5dd6b56e99 --- /dev/null +++ b/apps/core/src/modules/membership/providers/provider.interface.ts @@ -0,0 +1,26 @@ +import type { MembershipPlan } from '../membership.types' + +export interface NormalizedBillingEvent { + eventId: string + provider: string + type: 'activated' | 'renewed' | 'on_hold' | 'cancelled' | 'plan_changed' + customerId: string + subscriptionId: string + plan?: MembershipPlan + currentPeriodEnd: Date + readerId: string +} + +export interface PaymentProviderAdapter { + createCheckout: (input: { + reader: { id: string; email?: string | null; name?: string | null } + plan: MembershipPlan + }) => Promise<{ checkoutUrl: string }> + + verifyAndParseWebhook: ( + rawBody: Buffer | string, + headers: Record, + ) => Promise + + getPortalUrl?: (customerId: string) => Promise +} diff --git a/apps/core/test/setup-global.ts b/apps/core/test/setup-global.ts index da801d92bfc..3da0cf13dc1 100644 --- a/apps/core/test/setup-global.ts +++ b/apps/core/test/setup-global.ts @@ -6,6 +6,8 @@ process.env.NODE_ENV ??= 'development' process.env.MX_ENCRYPT_KEY ??= '593f62860255feb0a914534a43814b9809cc7534da7f5485cd2e3d3c8609acab' process.env.SNOWFLAKE_WORKER_ID ??= '1' +process.env.DODO_API_KEY ??= 'test-dodo-api-key' +process.env.DODO_WEBHOOK_KEY ??= 'test-dodo-webhook-key' vi.mock('~/utils/schedule.util', () => ({ scheduleManager: { diff --git a/apps/core/test/src/modules/membership/dodo.provider.spec.ts b/apps/core/test/src/modules/membership/dodo.provider.spec.ts new file mode 100644 index 00000000000..c9b5d9a6a95 --- /dev/null +++ b/apps/core/test/src/modules/membership/dodo.provider.spec.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AppErrorCode } from '~/common/errors' +import { DodoProvider } from '~/modules/membership/providers/dodo.provider' + +const verifyMock = vi.fn() +const checkoutCreateMock = vi.fn() + +vi.mock('standardwebhooks', () => ({ + Webhook: class MockWebhook { + verify = verifyMock + }, +})) + +vi.mock('dodopayments', () => ({ + default: class MockDodoPayments { + checkoutSessions = { create: checkoutCreateMock } + }, +})) + +describe('DodoProvider', () => { + let configsService: { get: ReturnType } + + beforeEach(() => { + verifyMock.mockReset() + checkoutCreateMock.mockReset() + + configsService = { + get: vi.fn().mockResolvedValue({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', + }), + } + }) + + describe('createCheckout', () => { + it('creates a checkout session and returns the checkout url', async () => { + checkoutCreateMock.mockResolvedValue({ + session_id: 'sess_1', + checkout_url: 'https://checkout.dodopayments.com/sess_1', + }) + + const provider = new DodoProvider(configsService as any) + const result = await provider.createCheckout({ + reader: { id: 'reader-1', email: 'reader@example.com', name: 'Reader' }, + plan: 'monthly', + }) + + expect(result).toEqual({ + checkoutUrl: 'https://checkout.dodopayments.com/sess_1', + }) + expect(checkoutCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + product_cart: [{ product_id: 'prod_monthly', quantity: 1 }], + metadata: { readerId: 'reader-1' }, + }), + ) + }) + + it('uses the yearly product id for the yearly plan', async () => { + checkoutCreateMock.mockResolvedValue({ + session_id: 'sess_2', + checkout_url: 'https://checkout.dodopayments.com/sess_2', + }) + + const provider = new DodoProvider(configsService as any) + await provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'yearly', + }) + + expect(checkoutCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + product_cart: [{ product_id: 'prod_yearly', quantity: 1 }], + }), + ) + }) + }) + + describe('verifyAndParseWebhook', () => { + const headers = { + 'webhook-id': 'evt_1', + 'webhook-timestamp': '1700000000', + 'webhook-signature': 'v1,signature', + } + + it('maps subscription.active to activated', async () => { + verifyMock.mockReturnValue({ + type: 'subscription.active', + business_id: 'biz_1', + timestamp: '2026-01-01T00:00:00Z', + data: { + subscription_id: 'sub_1', + customer: { customer_id: 'cus_1' }, + metadata: { readerId: 'reader-1' }, + next_billing_date: '2026-02-01T00:00:00Z', + payment_frequency_interval: 'Month', + }, + }) + + const provider = new DodoProvider(configsService as any) + const event = await provider.verifyAndParseWebhook('{}', headers) + + expect(event).toEqual({ + eventId: 'evt_1', + provider: 'dodo', + type: 'activated', + customerId: 'cus_1', + subscriptionId: 'sub_1', + plan: 'monthly', + currentPeriodEnd: new Date('2026-02-01T00:00:00Z'), + readerId: 'reader-1', + }) + }) + + it.each([ + ['subscription.renewed', 'renewed'], + ['subscription.on_hold', 'on_hold'], + ['subscription.cancelled', 'cancelled'], + ['subscription.expired', 'cancelled'], + ['subscription.plan_changed', 'plan_changed'], + ] as const)('maps %s to %s', async (dodoType, expectedType) => { + verifyMock.mockReturnValue({ + type: dodoType, + business_id: 'biz_1', + timestamp: '2026-01-01T00:00:00Z', + data: { + subscription_id: 'sub_1', + customer: { customer_id: 'cus_1' }, + metadata: { readerId: 'reader-1' }, + next_billing_date: '2026-02-01T00:00:00Z', + payment_frequency_interval: 'Year', + }, + }) + + const provider = new DodoProvider(configsService as any) + const event = await provider.verifyAndParseWebhook('{}', headers) + + expect(event.type).toBe(expectedType) + expect(event.plan).toBe('yearly') + }) + + it('throws WebhookVerifyFailed when signature verification fails', async () => { + verifyMock.mockImplementation(() => { + throw new Error('invalid signature') + }) + + const provider = new DodoProvider(configsService as any) + + await expect( + provider.verifyAndParseWebhook('{}', headers), + ).rejects.toMatchObject({ code: AppErrorCode.WEBHOOK_VERIFY_FAILED }) + }) + + it('throws WebhookVerifyFailed when readerId metadata is missing', async () => { + verifyMock.mockReturnValue({ + type: 'subscription.active', + business_id: 'biz_1', + timestamp: '2026-01-01T00:00:00Z', + data: { + subscription_id: 'sub_1', + customer: { customer_id: 'cus_1' }, + metadata: {}, + next_billing_date: '2026-02-01T00:00:00Z', + }, + }) + + const provider = new DodoProvider(configsService as any) + + await expect( + provider.verifyAndParseWebhook('{}', headers), + ).rejects.toMatchObject({ code: AppErrorCode.WEBHOOK_VERIFY_FAILED }) + }) + + it('throws WebhookVerifyFailed for an unmapped event type', async () => { + verifyMock.mockReturnValue({ + type: 'payment.succeeded', + business_id: 'biz_1', + timestamp: '2026-01-01T00:00:00Z', + data: { + subscription_id: 'sub_1', + customer: { customer_id: 'cus_1' }, + metadata: { readerId: 'reader-1' }, + next_billing_date: '2026-02-01T00:00:00Z', + }, + }) + + const provider = new DodoProvider(configsService as any) + + await expect( + provider.verifyAndParseWebhook('{}', headers), + ).rejects.toMatchObject({ code: AppErrorCode.WEBHOOK_VERIFY_FAILED }) + }) + }) +}) diff --git a/apps/core/test/src/modules/membership/entitlement.service.spec.ts b/apps/core/test/src/modules/membership/entitlement.service.spec.ts new file mode 100644 index 00000000000..07c2494a071 --- /dev/null +++ b/apps/core/test/src/modules/membership/entitlement.service.spec.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' +import { EntitlementService } from '~/modules/membership/entitlement.service' +import type { MembershipRepository } from '~/modules/membership/membership.repository' +import type { MembershipRow } from '~/modules/membership/membership.types' + +const createMembership = ( + overrides: Partial = {}, +): MembershipRow => ({ + id: 'membership-1' as any, + readerId: 'reader-1' as any, + provider: 'dodo', + providerCustomerId: 'cus_1', + providerSubscriptionId: 'sub_1', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(now.getTime() + 1000 * 60 * 60), + createdAt: now, + updatedAt: now, + ...overrides, +}) + +const createService = () => { + const membershipRepository = createPgRepositoryMock() + const service = new EntitlementService(membershipRepository) + return { service, membershipRepository } +} + +describe('EntitlementService.isActiveMember', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns false when the reader has no membership row', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue(null) + + expect(await service.isActiveMember('reader-1')).toBe(false) + }) + + it('returns true for an active membership within the period', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue( + createMembership({ status: 'active' }), + ) + + expect(await service.isActiveMember('reader-1')).toBe(true) + }) + + it('returns true for an on_hold membership still within its grace period', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue( + createMembership({ + status: 'on_hold', + currentPeriodEnd: new Date(now.getTime() + 1000 * 60), + }), + ) + + expect(await service.isActiveMember('reader-1')).toBe(true) + }) + + it('returns false for a cancelled membership', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue( + createMembership({ status: 'cancelled' }), + ) + + expect(await service.isActiveMember('reader-1')).toBe(false) + }) + + it('returns false when the period has expired, even if status is active', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue( + createMembership({ + status: 'active', + currentPeriodEnd: new Date(now.getTime() - 1000), + }), + ) + + expect(await service.isActiveMember('reader-1')).toBe(false) + }) + + it('returns false when on_hold and the grace period has expired', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue( + createMembership({ + status: 'on_hold', + currentPeriodEnd: new Date(now.getTime() - 1000), + }), + ) + + expect(await service.isActiveMember('reader-1')).toBe(false) + }) +}) diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts new file mode 100644 index 00000000000..3480f1b41f6 --- /dev/null +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' +import type { BillingWebhookEventRepository } from '~/modules/membership/billing-webhook-event.repository' +import type { MembershipRepository } from '~/modules/membership/membership.repository' +import { MembershipService } from '~/modules/membership/membership.service' +import type { MembershipRow } from '~/modules/membership/membership.types' +import type { NormalizedBillingEvent } from '~/modules/membership/providers/provider.interface' + +const createMembership = ( + overrides: Partial = {}, +): MembershipRow => ({ + id: 'membership-1' as any, + readerId: 'reader-1' as any, + provider: 'dodo', + providerCustomerId: 'cus_1', + providerSubscriptionId: 'sub_1', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(now.getTime() + 1000 * 60 * 60), + createdAt: now, + updatedAt: now, + ...overrides, +}) + +const createEvent = ( + overrides: Partial = {}, +): NormalizedBillingEvent => ({ + eventId: 'evt_1', + provider: 'dodo', + type: 'activated', + customerId: 'cus_1', + subscriptionId: 'sub_1', + plan: 'monthly', + currentPeriodEnd: new Date(now.getTime() + 1000 * 60 * 60), + readerId: 'reader-1', + ...overrides, +}) + +const createService = () => { + const membershipRepository = createPgRepositoryMock() + const billingWebhookEventRepository = + createPgRepositoryMock() + + membershipRepository.findByProviderSubscriptionId.mockResolvedValue(null) + membershipRepository.findByReaderId.mockResolvedValue(null) + billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue(null) + billingWebhookEventRepository.create.mockResolvedValue({ + id: 'event-1' as any, + provider: 'dodo', + eventId: 'evt_1', + type: 'activated', + payload: {}, + processedAt: null, + receivedAt: now, + }) + + const service = new MembershipService( + membershipRepository, + billingWebhookEventRepository, + ) + + return { service, membershipRepository, billingWebhookEventRepository } +} + +describe('MembershipService', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('applyEvent idempotency', () => { + it('skips processing when (provider, event_id) already exists', async () => { + const { service, billingWebhookEventRepository, membershipRepository } = + createService() + billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({ + id: 'event-1' as any, + provider: 'dodo', + eventId: 'evt_1', + type: 'activated', + payload: {}, + processedAt: now, + receivedAt: now, + }) + + const result = await service.applyEvent(createEvent()) + + expect(result).toEqual({ applied: false }) + expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + }) + + it('inserts the webhook event before applying state and marks it processed', async () => { + const { service, billingWebhookEventRepository, membershipRepository } = + createService() + + const result = await service.applyEvent(createEvent()) + + expect(result).toEqual({ applied: true }) + expect(billingWebhookEventRepository.create).toHaveBeenCalledWith({ + provider: 'dodo', + eventId: 'evt_1', + type: 'activated', + payload: expect.objectContaining({ eventId: 'evt_1' }), + }) + expect(membershipRepository.create).toHaveBeenCalled() + expect(billingWebhookEventRepository.markProcessed).toHaveBeenCalledWith( + 'event-1', + expect.any(Date), + ) + }) + }) + + describe('state transitions', () => { + it('activated creates an active membership', async () => { + const { service, membershipRepository } = createService() + + await service.applyEvent(createEvent({ type: 'activated' })) + + expect(membershipRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ readerId: 'reader-1', status: 'active' }), + ) + }) + + it('renewed updates status to active and extends the period', async () => { + const { service, membershipRepository } = createService() + const existing = createMembership({ status: 'on_hold' }) + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + existing, + ) + + const newPeriodEnd = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30) + await service.applyEvent( + createEvent({ type: 'renewed', currentPeriodEnd: newPeriodEnd }), + ) + + expect(membershipRepository.update).toHaveBeenCalledWith( + existing.id, + expect.objectContaining({ + status: 'active', + currentPeriodEnd: newPeriodEnd, + }), + ) + }) + + it('on_hold updates status to on_hold', async () => { + const { service, membershipRepository } = createService() + const existing = createMembership({ status: 'active' }) + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + existing, + ) + + await service.applyEvent(createEvent({ type: 'on_hold' })) + + expect(membershipRepository.update).toHaveBeenCalledWith( + existing.id, + expect.objectContaining({ status: 'on_hold' }), + ) + }) + + it('cancelled updates status to cancelled', async () => { + const { service, membershipRepository } = createService() + const existing = createMembership({ status: 'active' }) + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + existing, + ) + + await service.applyEvent(createEvent({ type: 'cancelled' })) + + expect(membershipRepository.update).toHaveBeenCalledWith( + existing.id, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('plan_changed only updates the plan column', async () => { + const { service, membershipRepository } = createService() + const existing = createMembership({ status: 'active', plan: 'monthly' }) + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + existing, + ) + + await service.applyEvent( + createEvent({ type: 'plan_changed', plan: 'yearly' }), + ) + + expect(membershipRepository.update).toHaveBeenCalledWith(existing.id, { + plan: 'yearly', + }) + }) + + it('plan_changed is a no-op when no membership exists', async () => { + const { service, membershipRepository } = createService() + + await service.applyEvent( + createEvent({ type: 'plan_changed', plan: 'yearly' }), + ) + + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(membershipRepository.create).not.toHaveBeenCalled() + }) + }) + + describe('manual grant', () => { + let service: MembershipService + let membershipRepository: ReturnType< + typeof createPgRepositoryMock + > + + beforeEach(() => { + const created = createService() + service = created.service + membershipRepository = created.membershipRepository + }) + + it('grants a manual membership when no existing row exists', async () => { + membershipRepository.findByReaderId.mockResolvedValue(null) + membershipRepository.create.mockResolvedValue( + createMembership({ provider: 'manual' }), + ) + + const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24) + await service.grantManual('reader-1', { plan: 'monthly', expiresAt }) + + expect(membershipRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + readerId: 'reader-1', + provider: 'manual', + status: 'active', + currentPeriodEnd: expiresAt, + }), + ) + }) + + it('extends an existing manual grant', async () => { + const existing = createMembership({ + provider: 'manual', + status: 'cancelled', + }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + membershipRepository.update.mockResolvedValue(existing) + + const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24) + await service.grantManual('reader-1', { plan: 'yearly', expiresAt }) + + expect(membershipRepository.update).toHaveBeenCalledWith( + existing.id, + expect.objectContaining({ + provider: 'manual', + status: 'active', + currentPeriodEnd: expiresAt, + }), + ) + }) + + it('rejects when a live provider-managed subscription exists (active)', async () => { + const existing = createMembership({ provider: 'dodo', status: 'active' }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + + await expect( + service.grantManual('reader-1', { + plan: 'monthly', + expiresAt: new Date(now.getTime() + 1000), + }), + ).rejects.toThrow() + + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(membershipRepository.create).not.toHaveBeenCalled() + }) + + it('rejects when a live provider-managed subscription exists (on_hold in grace)', async () => { + const existing = createMembership({ + provider: 'dodo', + status: 'on_hold', + currentPeriodEnd: new Date(now.getTime() + 1000 * 60), + }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + + await expect( + service.grantManual('reader-1', { + plan: 'monthly', + expiresAt: new Date(now.getTime() + 1000), + }), + ).rejects.toThrow() + }) + + it('allows granting when the provider subscription has expired', async () => { + const existing = createMembership({ + provider: 'dodo', + status: 'on_hold', + currentPeriodEnd: new Date(now.getTime() - 1000), + }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + membershipRepository.update.mockResolvedValue(existing) + + await service.grantManual('reader-1', { + plan: 'monthly', + expiresAt: new Date(now.getTime() + 1000), + }) + + expect(membershipRepository.update).toHaveBeenCalled() + }) + }) + + describe('manual revoke', () => { + it('revokes a manual grant', async () => { + const { service, membershipRepository } = createService() + const existing = createMembership({ + provider: 'manual', + status: 'active', + }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + membershipRepository.update.mockResolvedValue(existing) + + await service.revokeManual('reader-1') + + expect(membershipRepository.update).toHaveBeenCalledWith(existing.id, { + status: 'cancelled', + }) + }) + + it('rejects revoking a provider-managed subscription', async () => { + const { service, membershipRepository } = createService() + const existing = createMembership({ provider: 'dodo', status: 'active' }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + + await expect(service.revokeManual('reader-1')).rejects.toThrow() + expect(membershipRepository.update).not.toHaveBeenCalled() + }) + + it('rejects revoking when no membership exists', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue(null) + + await expect(service.revokeManual('reader-1')).rejects.toThrow() + expect(membershipRepository.update).not.toHaveBeenCalled() + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d78d24eb0bb..498b3fa6f9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -624,6 +624,9 @@ importers: diff-match-patch: specifier: ^1.0.5 version: 1.0.5 + dodopayments: + specifier: ^2.42.2 + version: 2.42.2 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -753,6 +756,9 @@ importers: source-map-support: specifier: ^0.5.21 version: 0.5.21 + standardwebhooks: + specifier: ^1.0.0 + version: 1.0.0 thumbhash: specifier: 0.1.1 version: 0.1.1 @@ -7823,6 +7829,10 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dodopayments@2.42.2: + resolution: {integrity: sha512-XBF8zrnfkjAhX7OyrR3KauK6bH+oW3yZ+uqy6RBWI5XdZvrEtASabvvfHrBX3UGR3Bbxl3t1rkpcao+riKSOuw==} + hasBin: true + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -13822,7 +13832,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2(supports-color@5.5.0) - https-proxy-agent: 7.0.6(supports-color@5.5.0) + https-proxy-agent: 7.0.6 openai: 6.26.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 typebox: 1.1.38 @@ -17363,7 +17373,7 @@ snapshots: dependencies: debug: 4.3.7 notepack.io: 3.0.1 - socket.io-parser: 4.2.6(supports-color@5.5.0) + socket.io-parser: 4.2.6 transitivePeerDependencies: - supports-color @@ -19590,6 +19600,10 @@ snapshots: dependencies: esutils: 2.0.3 + dodopayments@2.42.2: + dependencies: + standardwebhooks: 1.0.0 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -20766,7 +20780,7 @@ snapshots: gaxios@7.1.5: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@5.5.0) + https-proxy-agent: 7.0.6 node-fetch: 3.3.2 transitivePeerDependencies: - supports-color @@ -21106,7 +21120,7 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@5.5.0): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@5.5.0) @@ -23166,7 +23180,7 @@ snapshots: find-cache-dir: 3.3.2 find-package-json: 1.2.0 get-port: 5.1.1 - https-proxy-agent: 7.0.6(supports-color@5.5.0) + https-proxy-agent: 7.0.6 lockfile: 1.0.4 rimraf: 5.0.10 semver: 7.8.5 @@ -23744,13 +23758,13 @@ snapshots: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.6(supports-color@5.5.0) - socket.io-parser: 4.2.6(supports-color@5.5.0) + socket.io-parser: 4.2.6 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.6(supports-color@5.5.0): + socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@5.5.0) @@ -23765,7 +23779,7 @@ snapshots: debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 - socket.io-parser: 4.2.6(supports-color@5.5.0) + socket.io-parser: 4.2.6 transitivePeerDependencies: - bufferutil - supports-color From f582f9bd7c2e237f30ca365e6e53d1b7def76835 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 19:10:22 +0800 Subject: [PATCH 04/37] fix(membership): close webhook idempotency race and narrow module exports Make BillingWebhookEventRepository.create the sole idempotency gate via onConflictDoNothing().returning() on (provider, event_id), returning null for a losing concurrent insert instead of throwing; applyEvent treats null as { applied: false } and drops the separate check-then-insert pre-check. Narrow MembershipModule exports to EntitlementService only, per the decoupling rule that it's the sole consumable export. --- .../billing-webhook-event.repository.ts | 7 ++-- .../modules/membership/membership.module.ts | 7 +--- .../modules/membership/membership.service.ts | 8 +---- .../membership.repository.pg.e2e.spec.ts | 36 ++++++++++++++++--- .../membership/membership.service.spec.ts | 15 +++----- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/core/src/modules/membership/billing-webhook-event.repository.ts b/apps/core/src/modules/membership/billing-webhook-event.repository.ts index ec7bc2ad37f..00447b0c0f4 100644 --- a/apps/core/src/modules/membership/billing-webhook-event.repository.ts +++ b/apps/core/src/modules/membership/billing-webhook-event.repository.ts @@ -40,7 +40,7 @@ export class BillingWebhookEventRepository extends BaseRepository { type: string payload: unknown receivedAt?: Date - }): Promise { + }): Promise { const id = this.snowflake.nextId() const [row] = await this.db .insert(billingWebhookEvents) @@ -52,8 +52,11 @@ export class BillingWebhookEventRepository extends BaseRepository { payload: input.payload as Record, receivedAt: input.receivedAt ?? new Date(), }) + .onConflictDoNothing({ + target: [billingWebhookEvents.provider, billingWebhookEvents.eventId], + }) .returning() - return mapRow(row) + return row ? mapRow(row) : null } async findByProviderAndEventId( diff --git a/apps/core/src/modules/membership/membership.module.ts b/apps/core/src/modules/membership/membership.module.ts index 3ca7f1c5b26..1ff5db0923c 100644 --- a/apps/core/src/modules/membership/membership.module.ts +++ b/apps/core/src/modules/membership/membership.module.ts @@ -14,11 +14,6 @@ import { DodoProvider } from './providers/dodo.provider' BillingWebhookEventRepository, DodoProvider, ], - exports: [ - MembershipService, - EntitlementService, - MembershipRepository, - DodoProvider, - ], + exports: [EntitlementService], }) export class MembershipModule {} diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 2dc603e7bf1..2a9a7e90bf8 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -30,19 +30,13 @@ export class MembershipService { async applyEvent( event: NormalizedBillingEvent, ): Promise<{ applied: boolean }> { - const existingWebhookEvent = - await this.billingWebhookEventRepository.findByProviderAndEventId( - event.provider, - event.eventId, - ) - if (existingWebhookEvent) return { applied: false } - const webhookEventRow = await this.billingWebhookEventRepository.create({ provider: event.provider, eventId: event.eventId, type: event.type, payload: event, }) + if (!webhookEventRow) return { applied: false } await this.applyMembershipState(event) await this.billingWebhookEventRepository.markProcessed( diff --git a/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts b/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts index 62471663748..024e3875581 100644 --- a/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts +++ b/apps/core/test/src/modules/membership/membership.repository.pg.e2e.spec.ts @@ -150,21 +150,47 @@ describe('Membership + billing webhook event repositories (real PG)', () => { ) }) - it('rejects a duplicate (provider, event_id) pair', async () => { - await billingWebhookEventRepository.create({ + it('skips a duplicate (provider, event_id) insert without throwing', async () => { + const first = await billingWebhookEventRepository.create({ provider: 'dodo', eventId: 'evt_dup', type: 'subscription.renewed', payload: {}, }) + expect(first).not.toBeNull() - await expect( + const second = await billingWebhookEventRepository.create({ + provider: 'dodo', + eventId: 'evt_dup', + type: 'subscription.renewed', + payload: {}, + }) + expect(second).toBeNull() + + const results = await Promise.allSettled([ billingWebhookEventRepository.create({ provider: 'dodo', - eventId: 'evt_dup', + eventId: 'evt_concurrent', type: 'subscription.renewed', payload: {}, }), - ).rejects.toThrow() + billingWebhookEventRepository.create({ + provider: 'dodo', + eventId: 'evt_concurrent', + type: 'subscription.renewed', + payload: {}, + }), + ]) + + const fulfilled = results.filter((r) => r.status === 'fulfilled') as Array< + PromiseFulfilledResult< + Awaited> + > + > + expect(fulfilled).toHaveLength(2) + const wins = fulfilled.filter((r) => r.value !== null) + const losses = fulfilled.filter((r) => r.value === null) + expect(wins).toHaveLength(1) + expect(losses).toHaveLength(1) }) }) diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 3480f1b41f6..0839df170c4 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -74,23 +74,16 @@ describe('MembershipService', () => { }) describe('applyEvent idempotency', () => { - it('skips processing when (provider, event_id) already exists', async () => { + it('skips processing when the insert loses the (provider, event_id) conflict race', async () => { const { service, billingWebhookEventRepository, membershipRepository } = createService() - billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({ - id: 'event-1' as any, - provider: 'dodo', - eventId: 'evt_1', - type: 'activated', - payload: {}, - processedAt: now, - receivedAt: now, - }) + billingWebhookEventRepository.create.mockResolvedValue(null) const result = await service.applyEvent(createEvent()) expect(result).toEqual({ applied: false }) - expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.create).toHaveBeenCalled() + expect(billingWebhookEventRepository.markProcessed).not.toHaveBeenCalled() expect(membershipRepository.create).not.toHaveBeenCalled() expect(membershipRepository.update).not.toHaveBeenCalled() }) From 8d4bf9945830a2cdac96829c3677774826803ede Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 19:32:01 +0800 Subject: [PATCH 05/37] feat(membership): add HTTP API controller for checkout, status, webhook and admin member management Adds POST /membership/checkout, GET /membership/status, POST /membership/webhook/:provider, and the owner-only members list/grant/revoke endpoints. Webhook signature verification needs the exact request bytes, so the Fastify adapter gains a narrow preParsing hook that buffers the raw body only for the membership webhook route. --- .../src/common/adapters/fastify.adapter.ts | 19 + .../membership/membership.controller.ts | 149 ++++++ .../modules/membership/membership.module.ts | 2 + .../membership/membership.repository.ts | 51 ++- .../modules/membership/membership.service.ts | 4 + .../modules/membership/membership.types.ts | 9 + .../src/transformers/get-req.transformer.ts | 1 + .../membership.controller.e2e-spec.ts | 429 ++++++++++++++++++ 8 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 apps/core/src/modules/membership/membership.controller.ts create mode 100644 apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts diff --git a/apps/core/src/common/adapters/fastify.adapter.ts b/apps/core/src/common/adapters/fastify.adapter.ts index fe62fcc7982..20604b46766 100644 --- a/apps/core/src/common/adapters/fastify.adapter.ts +++ b/apps/core/src/common/adapters/fastify.adapter.ts @@ -1,3 +1,5 @@ +import { Readable } from 'node:stream' + import fastifyCookie from '@fastify/cookie' import FastifyMultipart from '@fastify/multipart' import { Logger } from '@nestjs/common' @@ -66,6 +68,23 @@ app.register(FastifyMultipart, { }, }) +const MEMBERSHIP_WEBHOOK_PATH_SEGMENT = '/membership/webhook/' + +app.getInstance().addHook('preParsing', async (request, _reply, payload) => { + if (!request.url.includes(MEMBERSHIP_WEBHOOK_PATH_SEGMENT)) { + return payload + } + + const chunks: Buffer[] = [] + for await (const chunk of payload) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const rawBody = Buffer.concat(chunks) + Object.assign(request, { rawBody }) + + return Readable.from(rawBody) +}) + app.getInstance().addHook('onRequest', (request, reply, done) => { const origin = request.headers.origin if (!origin) { diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts new file mode 100644 index 00000000000..15d615e8e13 --- /dev/null +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -0,0 +1,149 @@ +import { + Body, + Delete, + Get, + Headers, + Param, + Post, + Put, + Query, + Req, +} from '@nestjs/common' +import { createZodDto } from 'nestjs-zod' +import { z } from 'zod' + +import { DEMO_MODE } from '~/app.config' +import { ApiController } from '~/common/decorators/api-controller.decorator' +import { Auth } from '~/common/decorators/auth.decorator' +import { CurrentUser } from '~/common/decorators/current-user.decorator' +import { ReaderAuth } from '~/common/decorators/reader-auth.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import type { FastifyBizRequest } from '~/transformers/get-req.transformer' + +import type { SessionUser } from '../auth/auth.types' +import { ConfigsService } from '../configs/configs.service' +import { MembershipService } from './membership.service' +import { DodoProvider } from './providers/dodo.provider' +import type { PaymentProviderAdapter } from './providers/provider.interface' + +const CheckoutSchema = z.object({ + plan: z.enum(['monthly', 'yearly']), +}) +class CheckoutDto extends createZodDto(CheckoutSchema) {} + +const ManualGrantSchema = z.object({ + plan: z.enum(['monthly', 'yearly']), + expiresAt: z.coerce.date(), +}) +class ManualGrantDto extends createZodDto(ManualGrantSchema) {} + +const MembersListQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + size: z.coerce.number().int().positive().max(100).default(20), +}) +class MembersListQueryDto extends createZodDto(MembersListQuerySchema) {} + +const assertNotDemoMode = () => { + if (DEMO_MODE) { + throw createAppException(AppErrorCode.DEMO_FORBIDDEN) + } +} + +@ApiController('membership') +export class MembershipController { + constructor( + private readonly membershipService: MembershipService, + private readonly configsService: ConfigsService, + private readonly dodoProvider: DodoProvider, + ) {} + + private resolveProvider(provider: string): PaymentProviderAdapter { + if (provider === 'dodo') return this.dodoProvider + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + @ReaderAuth() + @Post('/checkout') + async checkout(@Body() body: CheckoutDto, @CurrentUser() user: SessionUser) { + assertNotDemoMode() + + const membershipConfig = await this.configsService.get('membership') + if (!membershipConfig.enabled || !membershipConfig.provider) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + const adapter = this.resolveProvider(membershipConfig.provider) + return adapter.createCheckout({ + reader: { id: user.id, email: user.email, name: user.name }, + plan: body.plan, + }) + } + + @ReaderAuth() + @Get('/status') + async status(@Req() req: FastifyBizRequest) { + const readerId = req.readerId! + const membership = await this.membershipService.getByReaderId(readerId) + if (!membership) { + return { status: 'none' as const } + } + + return { + status: membership.status, + plan: membership.plan, + provider: membership.provider, + currentPeriodEnd: membership.currentPeriodEnd, + } + } + + @Post('/webhook/:provider') + async webhook( + @Param('provider') provider: string, + @Req() req: FastifyBizRequest, + @Headers() headers: Record, + ) { + assertNotDemoMode() + + if (provider !== 'dodo') { + throw createAppException(AppErrorCode.WEBHOOK_VERIFY_FAILED) + } + const adapter = this.dodoProvider + const rawBody = req.rawBody ?? Buffer.alloc(0) + const event = await adapter.verifyAndParseWebhook(rawBody, headers) + const result = await this.membershipService.applyEvent(event) + return { ok: true, applied: result.applied } + } + + @Auth() + @Get('/members') + async members(@Query() query: MembersListQueryDto) { + const result = await this.membershipService.listMembers( + query.page, + query.size, + ) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) + } + + @Auth() + @Put('/members/:readerId') + async grant( + @Param('readerId') readerId: string, + @Body() body: ManualGrantDto, + ) { + return this.membershipService.grantManual(readerId, { + plan: body.plan, + expiresAt: body.expiresAt, + }) + } + + @Auth() + @Delete('/members/:readerId') + async revoke(@Param('readerId') readerId: string) { + return this.membershipService.revokeManual(readerId) + } +} diff --git a/apps/core/src/modules/membership/membership.module.ts b/apps/core/src/modules/membership/membership.module.ts index 1ff5db0923c..397e25a096d 100644 --- a/apps/core/src/modules/membership/membership.module.ts +++ b/apps/core/src/modules/membership/membership.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common' import { BillingWebhookEventRepository } from './billing-webhook-event.repository' import { EntitlementService } from './entitlement.service' +import { MembershipController } from './membership.controller' import { MembershipRepository } from './membership.repository' import { MembershipService } from './membership.service' import { DodoProvider } from './providers/dodo.provider' @Module({ + controllers: [MembershipController], providers: [ MembershipService, EntitlementService, diff --git a/apps/core/src/modules/membership/membership.repository.ts b/apps/core/src/modules/membership/membership.repository.ts index e900b699c55..e4a0e2fc7bf 100644 --- a/apps/core/src/modules/membership/membership.repository.ts +++ b/apps/core/src/modules/membership/membership.repository.ts @@ -1,10 +1,11 @@ import { Inject, Injectable } from '@nestjs/common' -import { eq } from 'drizzle-orm' +import { desc, eq, sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' -import { memberships } from '~/database/schema' +import { memberships, readers } from '~/database/schema' import { BaseRepository, + type PaginationResult, toEntityId, } from '~/processors/database/base.repository' import type { AppDatabase } from '~/processors/database/postgres.provider' @@ -12,6 +13,7 @@ import { type EntityId, parseEntityId } from '~/shared/id/entity-id' import { SnowflakeService } from '~/shared/id/snowflake.service' import type { + MembershipMemberRow, MembershipPlan, MembershipProvider, MembershipRow, @@ -135,4 +137,49 @@ export class MembershipRepository extends BaseRepository { .returning() return row ? mapRow(row) : null } + + async listMembers( + page: number, + size: number, + ): Promise> { + const normalizedPage = Math.max(1, page) + const normalizedSize = Math.min(100, Math.max(1, size)) + const offset = (normalizedPage - 1) * normalizedSize + + const [rows, [{ count }]] = await Promise.all([ + this.db + .select({ + membership: memberships, + reader: { + id: readers.id, + email: readers.email, + name: readers.name, + handle: readers.handle, + }, + }) + .from(memberships) + .innerJoin(readers, eq(memberships.readerId, readers.id)) + .orderBy(desc(memberships.createdAt)) + .limit(normalizedSize) + .offset(offset), + this.db.select({ count: sql`count(*)::int` }).from(memberships), + ]) + + return { + data: rows.map((row) => ({ + ...mapRow(row.membership), + reader: { + id: toEntityId(row.reader.id) as EntityId, + email: row.reader.email, + name: row.reader.name, + handle: row.reader.handle, + }, + })), + pagination: this.paginationOf( + Number(count ?? 0), + normalizedPage, + normalizedSize, + ), + } + } } diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 2a9a7e90bf8..25f1445e16f 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -27,6 +27,10 @@ export class MembershipService { return this.membershipRepository.findByReaderId(readerId) } + async listMembers(page: number, size: number) { + return this.membershipRepository.listMembers(page, size) + } + async applyEvent( event: NormalizedBillingEvent, ): Promise<{ applied: boolean }> { diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 65150d65eb2..f303ee06d03 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -20,6 +20,15 @@ export interface MembershipRow { updatedAt: Date | null } +export interface MembershipMemberRow extends MembershipRow { + reader: { + id: EntityId + email: string | null + name: string | null + handle: string | null + } +} + export interface BillingWebhookEventRow { id: EntityId provider: string diff --git a/apps/core/src/transformers/get-req.transformer.ts b/apps/core/src/transformers/get-req.transformer.ts index 2b89167f879..f6fd799702b 100644 --- a/apps/core/src/transformers/get-req.transformer.ts +++ b/apps/core/src/transformers/get-req.transformer.ts @@ -15,6 +15,7 @@ type BizRequest = { isAuthenticated: boolean token?: string readerId?: string + rawBody?: Buffer } export type FastifyBizRequest = FastifyRequest & BizRequest diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts new file mode 100644 index 00000000000..d91cdc4391f --- /dev/null +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -0,0 +1,429 @@ +import * as schema from '@mx-space/db-schema/schema' +import type { ModuleMetadata } from '@nestjs/common' +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { createIsolatedPgDatabase } from 'test/helper/pg-testcontainer' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' + +import { AppErrorCode, createAppException } from '~/common/errors' +import { PG_DB_TOKEN } from '~/constants/system.constant' +import { AuthService } from '~/modules/auth/auth.service' +import { ConfigsService } from '~/modules/configs/configs.service' +import { BillingWebhookEventRepository } from '~/modules/membership/billing-webhook-event.repository' +import { EntitlementService } from '~/modules/membership/entitlement.service' +import { MembershipController } from '~/modules/membership/membership.controller' +import { MembershipRepository } from '~/modules/membership/membership.repository' +import { MembershipService } from '~/modules/membership/membership.service' +import { DodoProvider } from '~/modules/membership/providers/dodo.provider' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +import { createE2EApp } from '../../../helper/create-e2e-app' + +const snowflake = new SnowflakeService() + +const readerId = snowflake.nextId() +const otherReaderId = snowflake.nextId() +const liveSubReaderId = snowflake.nextId() + +const readerUser = { + id: readerId, + email: 'reader@example.com', + name: 'Reader One', + role: 'reader' as const, +} + +const membershipConfig: { + enabled: boolean + provider: string | undefined + monthlyProductId: string + yearlyProductId: string +} = { + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', +} + +const configsServiceMock = { + get: vi.fn(async (key: string) => + key === 'membership' ? membershipConfig : {}, + ), +} + +const authServiceMock = { + getSessionUser: vi.fn(async (req: { headers?: Record }) => { + const header = req?.headers?.['x-test-reader'] + if (header === 'reader') { + return { user: readerUser, session: { token: 'reader-token' } } + } + return null + }), +} + +const createCheckoutMock = vi.fn( + async (input: { reader: { id: string }; plan: string }) => ({ + checkoutUrl: `https://checkout.example/${input.plan}/${input.reader.id}`, + }), +) + +const verifyAndParseWebhookMock = vi.fn( + async (rawBody: Buffer, headers: Record) => { + if (headers['x-signature'] !== 'valid') { + throw createAppException(AppErrorCode.WEBHOOK_VERIFY_FAILED) + } + const body = JSON.parse(rawBody.toString('utf8')) + return { + eventId: body.eventId, + provider: 'dodo', + type: body.type, + customerId: body.customerId, + subscriptionId: body.subscriptionId, + plan: body.plan, + currentPeriodEnd: new Date(body.currentPeriodEnd), + readerId: body.readerId, + } + }, +) + +const dodoProviderMock = { + createCheckout: createCheckoutMock, + verifyAndParseWebhook: verifyAndParseWebhookMock, +} + +const membershipModule: ModuleMetadata = { + controllers: [MembershipController], + providers: [ + MembershipService, + MembershipRepository, + BillingWebhookEventRepository, + EntitlementService, + { provide: SnowflakeService, useValue: snowflake }, + { provide: DodoProvider, useValue: dodoProviderMock }, + { provide: AuthService, useValue: authServiceMock }, + { provide: ConfigsService, useValue: configsServiceMock }, + ], +} + +let pool: Pool +let db: Awaited> + +beforeAll(async () => { + db = await createIsolatedPgDatabase() + pool = new Pool({ connectionString: db.getConnectionUri(), max: 4 }) + const drizzleDb = drizzle(pool, { schema }) as unknown as AppDatabase + + membershipModule.providers!.push({ + provide: PG_DB_TOKEN, + useValue: drizzleDb, + }) + + await drizzleDb.insert(schema.readers).values([ + { id: readerId, name: 'Reader One', role: 'reader' }, + { id: otherReaderId, name: 'Reader Two', role: 'reader' }, + { id: liveSubReaderId, name: 'Reader Three', role: 'reader' }, + ]) +}, 120_000) + +afterAll(async () => { + await pool?.end() + await db?.drop() +}) + +const proxy = createE2EApp(membershipModule) + +describe('MembershipController (e2e)', () => { + beforeEach(() => { + membershipConfig.enabled = true + membershipConfig.provider = 'dodo' + createCheckoutMock.mockClear() + verifyAndParseWebhookMock.mockClear() + }) + + describe('POST /membership/checkout', () => { + it('returns a checkout url for an authenticated reader', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'reader', + 'content-type': 'application/json', + }, + payload: { plan: 'monthly' }, + }) + + expect(res.statusCode).toBe(201) + expect(res.json()).toMatchObject({ + data: { checkout_url: expect.stringContaining('monthly') }, + }) + expect(createCheckoutMock).toHaveBeenCalledWith({ + reader: { + id: readerId, + email: readerUser.email, + name: readerUser.name, + }, + plan: 'monthly', + }) + }) + + it('rejects anonymous callers with 401', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { 'content-type': 'application/json' }, + payload: { plan: 'monthly' }, + }) + + expect(res.statusCode).toBe(401) + }) + + it('returns MEMBERSHIP_PROVIDER_NOT_CONFIGURED when membership is disabled', async () => { + membershipConfig.enabled = false + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'reader', + 'content-type': 'application/json', + }, + payload: { plan: 'monthly' }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: 'MEMBERSHIP_PROVIDER_NOT_CONFIGURED' }, + }) + expect(createCheckoutMock).not.toHaveBeenCalled() + }) + + it('returns MEMBERSHIP_PROVIDER_NOT_CONFIGURED when no provider is chosen', async () => { + membershipConfig.provider = undefined + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'reader', + 'content-type': 'application/json', + }, + payload: { plan: 'yearly' }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: 'MEMBERSHIP_PROVIDER_NOT_CONFIGURED' }, + }) + }) + }) + + describe('GET /membership/status', () => { + it('returns a none shape when the reader has no membership', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/status', + headers: { 'x-test-reader': 'reader' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ data: { status: 'none' } }) + }) + + it('rejects anonymous callers with 401', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/status', + }) + + expect(res.statusCode).toBe(401) + }) + }) + + describe('POST /membership/webhook/:provider', () => { + it('applies a valid event and is idempotent on double delivery', async () => { + const payload = { + eventId: 'evt_checkout_1', + type: 'activated', + customerId: 'cus_1', + subscriptionId: 'sub_1', + plan: 'monthly', + currentPeriodEnd: new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000, + ).toISOString(), + readerId, + } + + const first = await proxy.app.inject({ + method: 'POST', + url: '/membership/webhook/dodo', + headers: { 'x-signature': 'valid', 'content-type': 'application/json' }, + payload, + }) + expect(first.statusCode).toBe(201) + expect(first.json()).toMatchObject({ data: { ok: true, applied: true } }) + + const second = await proxy.app.inject({ + method: 'POST', + url: '/membership/webhook/dodo', + headers: { 'x-signature': 'valid', 'content-type': 'application/json' }, + payload, + }) + expect(second.statusCode).toBe(201) + expect(second.json()).toMatchObject({ + data: { ok: true, applied: false }, + }) + + const status = await proxy.app.inject({ + method: 'GET', + url: '/membership/status', + headers: { 'x-test-reader': 'reader' }, + }) + expect(status.json()).toMatchObject({ + data: { status: 'active', plan: 'monthly', provider: 'dodo' }, + }) + }) + + it('returns 400 on signature verification failure', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/webhook/dodo', + headers: { + 'x-signature': 'invalid', + 'content-type': 'application/json', + }, + payload: { eventId: 'evt_bad' }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: 'WEBHOOK_VERIFY_FAILED' }, + }) + }) + + it('returns 400 for an unknown provider param', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/webhook/unknown-provider', + headers: { 'content-type': 'application/json' }, + payload: {}, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: 'WEBHOOK_VERIFY_FAILED' }, + }) + expect(verifyAndParseWebhookMock).not.toHaveBeenCalled() + }) + }) + + describe('GET /membership/members', () => { + it('rejects callers without the owner test-token header', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/members', + }) + + expect(res.statusCode).toBe(401) + }) + + it('returns a paginated member list for the owner', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/members', + headers: { 'test-token': '1' }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(Array.isArray(body.data)).toBe(true) + expect(body.meta.pagination).toMatchObject({ page: 1, size: 20 }) + expect(body.data.some((row: any) => row.reader.id === readerId)).toBe( + true, + ) + }) + }) + + describe('PUT /membership/members/:readerId', () => { + beforeAll(async () => { + const membershipRepository = proxy.app.get(MembershipRepository) + await membershipRepository.create({ + readerId: liveSubReaderId, + provider: 'dodo', + providerCustomerId: 'cus_live', + providerSubscriptionId: 'sub_live', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }) + }) + + it('grants a manual membership', async () => { + const expiresAt = new Date( + Date.now() + 60 * 24 * 60 * 60 * 1000, + ).toISOString() + + const res = await proxy.app.inject({ + method: 'PUT', + url: `/membership/members/${otherReaderId}`, + headers: { 'test-token': '1', 'content-type': 'application/json' }, + payload: { plan: 'yearly', expiresAt }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ + data: { status: 'active', plan: 'yearly', provider: 'manual' }, + }) + }) + + it('rejects grant when the reader has a live provider-managed subscription', async () => { + const res = await proxy.app.inject({ + method: 'PUT', + url: `/membership/members/${liveSubReaderId}`, + headers: { 'test-token': '1', 'content-type': 'application/json' }, + payload: { + plan: 'monthly', + expiresAt: new Date(Date.now() + 1000 * 60).toISOString(), + }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: 'INVALID_PARAMETER' }, + }) + }) + + it('rejects manual grant/revoke callers without the owner test-token header', async () => { + const res = await proxy.app.inject({ + method: 'PUT', + url: `/membership/members/${otherReaderId}`, + headers: { 'content-type': 'application/json' }, + payload: { plan: 'monthly', expiresAt: new Date().toISOString() }, + }) + + expect(res.statusCode).toBe(401) + }) + }) + + describe('DELETE /membership/members/:readerId', () => { + it('revokes a manual grant', async () => { + const res = await proxy.app.inject({ + method: 'DELETE', + url: `/membership/members/${otherReaderId}`, + headers: { 'test-token': '1' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ + data: { status: 'cancelled', provider: 'manual' }, + }) + }) + }) +}) From e22b05ad797ecf4f6c0ab09d50c6548f846cc1dc Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 19:48:42 +0800 Subject: [PATCH 06/37] feat(post): enforce paywall gate on premium post detail responses Post detail handlers (getById/getByCateAndSlug) now truncate premium Lexical content and teaser text for requesters who are neither the owner nor an active/on_hold member, using the existing pure Lexical truncation helpers and EntitlementService.isActiveMember as the sole membership entry point. Reader identity is resolved via the global RolesGuard's optional request.readerId (@CurrentReaderId), mirroring how HasAdminAccess already works on public routes. Write-side: isPremium is added to the post write schema and rejected with PREMIUM_REQUIRES_LEXICAL whenever the effective post state (after merging with the existing document on update) is premium but not Lexical. meta.types.ts gains a paywall shape and PostMetaBuilder.paywall(). Post detail responses are not Redis-cached today, so no cache-key changes were needed. --- apps/core/src/common/response/meta.types.ts | 9 + .../src/modules/post/post-meta-builder.ts | 6 + apps/core/src/modules/post/post.controller.ts | 52 +++ apps/core/src/modules/post/post.module.ts | 2 + apps/core/src/modules/post/post.repository.ts | 3 + apps/core/src/modules/post/post.schema.ts | 1 + apps/core/src/modules/post/post.service.ts | 20 + apps/core/src/modules/post/post.types.ts | 2 + .../test/mock/modules/entitlement.mock.ts | 12 + .../admin/posts-admin.contract.spec.ts | 2 + .../test/src/contracts/post.contract.spec.ts | 2 + .../yohaku/post-detail.contract.spec.ts | 2 + .../yohaku/post-list.contract.spec.ts | 2 + .../src/modules/post/post-paywall.e2e-spec.ts | 342 ++++++++++++++++++ .../src/modules/post/post-skill.e2e-spec.ts | 5 + .../src/modules/post/post.controller.spec.ts | 6 + .../src/modules/post/post.service.spec.ts | 84 +++++ 17 files changed, 552 insertions(+) create mode 100644 apps/core/test/mock/modules/entitlement.mock.ts create mode 100644 apps/core/test/src/modules/post/post-paywall.e2e-spec.ts diff --git a/apps/core/src/common/response/meta.types.ts b/apps/core/src/common/response/meta.types.ts index 6bba71320d0..f93a9c9248e 100644 --- a/apps/core/src/common/response/meta.types.ts +++ b/apps/core/src/common/response/meta.types.ts @@ -114,6 +114,13 @@ export const SummaryMetaSchema = z }) .strict() +export const PaywallMetaSchema = z + .object({ + locked: z.boolean(), + previewBlocks: z.number().optional(), + }) + .strict() + export const BaseResponseMetaSchema = z.object({ pagination: PaginationSchema.optional(), view: z.string().optional(), @@ -135,6 +142,7 @@ export const PostResponseMetaSchema = BaseResponseMetaSchema.extend({ articles: z.record(z.string(), RelatedRefSchema).optional(), summary: SummaryMetaSchema.optional(), skills: z.array(SkillBundleViewSchema).optional(), + paywall: PaywallMetaSchema.optional(), }) export const NoteResponseMetaSchema = BaseResponseMetaSchema.extend({ @@ -159,6 +167,7 @@ export type RelatedRef = z.infer export type ArticleRefMap = Record export type InsightsMeta = z.infer export type SummaryMeta = z.infer +export type PaywallMeta = z.infer export type BaseResponseMeta = z.infer export type PostResponseMeta = z.infer export type NoteResponseMeta = z.infer diff --git a/apps/core/src/modules/post/post-meta-builder.ts b/apps/core/src/modules/post/post-meta-builder.ts index 2736ce2de86..df8db31764b 100644 --- a/apps/core/src/modules/post/post-meta-builder.ts +++ b/apps/core/src/modules/post/post-meta-builder.ts @@ -1,6 +1,7 @@ import type { ArticleRefMap, InsightsMeta, + PaywallMeta, PostResponseMeta, RelatedRef, SummaryMeta, @@ -40,4 +41,9 @@ export class PostMetaBuilder extends MetaObjectBuilder< ;(this.meta as PostResponseMeta).skills = value return this } + + paywall(value: PaywallMeta): this { + ;(this.meta as PostResponseMeta).paywall = value + return this + } } diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index 2bf35bc0b21..feaf58ca3eb 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -12,6 +12,7 @@ import { import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { BypassCaseTransform } from '~/common/decorators/bypass-case-transform.decorator' +import { CurrentReaderId } from '~/common/decorators/current-user.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import type { IpRecord } from '~/common/decorators/ip.decorator' import { IpLocation } from '~/common/decorators/ip.decorator' @@ -25,6 +26,7 @@ import type { } from '~/common/response/meta.types' import { MetaObjectBuilder } from '~/common/response/meta-builder' import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { CountingService } from '~/processors/helper/helper.counting.service' import { applyArticleTranslationInPlace, @@ -35,6 +37,10 @@ import { type EntryRule, TranslationService, } from '~/processors/helper/helper.translation.service' +import { + renderTeaserText, + truncateLexicalContent, +} from '~/processors/helper/lexical-truncate.util' import { EntityIdDto } from '~/shared/dto/id.dto' import { AiInsightsService } from '../ai/ai-insights/ai-insights.service' @@ -74,8 +80,34 @@ export class PostController { private readonly enrichmentService: EnrichmentService, private readonly translationEntryService: TranslationEntryService, private readonly snippetService: SnippetService, + private readonly entitlementService: EntitlementService, ) {} + private async applyPaywall( + doc: Record, + metaBuilder: PostMetaBuilder, + isOwner: boolean, + readerId?: string, + ) { + if (!doc.isPremium) return + + const isEntitled = + isOwner || + (readerId + ? await this.entitlementService.isActiveMember(readerId) + : false) + + if (isEntitled) { + metaBuilder.paywall({ locked: false }) + return + } + + const previewBlocks = (doc.meta as any)?.paywall?.previewBlocks ?? 3 + doc.content = truncateLexicalContent(doc.content, previewBlocks) + doc.text = renderTeaserText(doc.content) + metaBuilder.paywall({ locked: true, previewBlocks }) + } + private async batchCategoryEntryTranslations( lang: string, posts: Array<{ category?: { id: unknown } | null } | null | undefined>, @@ -196,6 +228,7 @@ export class PostController { @IpLocation() ip: IpRecord, @HasAdminAccess() isAuthenticated: boolean, @Lang() lang?: string, + @CurrentReaderId() readerId?: string, ) { const [last] = await this.postService.findRecent(1, { publishedOnly: !isAuthenticated, @@ -211,6 +244,7 @@ export class PostController { ip, isAuthenticated, lang, + readerId, ) } @@ -220,6 +254,7 @@ export class PostController { @Param() params: EntityIdDto, @HasAdminAccess() isAuthenticated: boolean, @Lang() lang?: string, + @CurrentReaderId() readerId?: string, ) { const { id } = params const doc = await this.postService.findById(id) @@ -288,6 +323,14 @@ export class PostController { metaBuilder.translation(translationMap) if (skills.length > 0) metaBuilder.skills(skills) + + await this.applyPaywall( + docData as Record, + metaBuilder, + !!isAuthenticated, + readerId, + ) + return withMeta(docData, metaBuilder.build()) } @@ -299,6 +342,7 @@ export class PostController { @IpLocation() { ip }: IpRecord, @HasAdminAccess() isAuthenticated?: boolean, @Lang() lang?: string, + @CurrentReaderId() readerId?: string, ) { const { category, slug } = params const postDocument = await this.postService.getPostBySlug( @@ -420,6 +464,14 @@ export class PostController { metaBuilder.translation(translationMap) if (skills.length > 0) metaBuilder.skills(skills) + + await this.applyPaywall( + postData as Record, + metaBuilder, + !!isAuthenticated, + readerId, + ) + return withMeta(postData, metaBuilder.build()) } diff --git a/apps/core/src/modules/post/post.module.ts b/apps/core/src/modules/post/post.module.ts index 882a56660cf..fbeb05b51ae 100644 --- a/apps/core/src/modules/post/post.module.ts +++ b/apps/core/src/modules/post/post.module.ts @@ -6,6 +6,7 @@ import { AiModule } from '../ai/ai.module' import { CommentModule } from '../comment/comment.module' import { DraftModule } from '../draft/draft.module' import { EnrichmentModule } from '../enrichment/enrichment.module' +import { MembershipModule } from '../membership/membership.module' import { SlugTrackerModule } from '../slug-tracker/slug-tracker.module' import { SnippetModule } from '../snippet/snippet.module' import { PostController } from './post.controller' @@ -20,6 +21,7 @@ import { PostService } from './post.service' AiModule, EnrichmentModule, SnippetModule, + MembershipModule, forwardRef(() => CommentModule), ], controllers: [PostController], diff --git a/apps/core/src/modules/post/post.repository.ts b/apps/core/src/modules/post/post.repository.ts index 22fd7cf0e52..1f142a02438 100644 --- a/apps/core/src/modules/post/post.repository.ts +++ b/apps/core/src/modules/post/post.repository.ts @@ -53,6 +53,7 @@ const mapBase = (row: PostRowSource): PostRow => ({ categoryId: toEntityId(row.categoryId) as EntityId, copyright: row.copyright, isPublished: row.isPublished, + isPremium: row.isPremium, readCount: row.readCount, likeCount: row.likeCount, pinAt: row.pinAt, @@ -197,6 +198,7 @@ export class PostRepository extends BaseRepository { categoryId: parseEntityId(input.categoryId), copyright: input.copyright ?? true, isPublished: input.isPublished ?? true, + isPremium: input.isPremium ?? false, pinAt: input.pinAt ?? null, pinOrder: input.pinOrder ?? null, }) @@ -225,6 +227,7 @@ export class PostRepository extends BaseRepository { update.categoryId = parseEntityId(patch.categoryId) if (patch.copyright !== undefined) update.copyright = patch.copyright if (patch.isPublished !== undefined) update.isPublished = patch.isPublished + if (patch.isPremium !== undefined) update.isPremium = patch.isPremium if (patch.pinAt !== undefined) update.pinAt = patch.pinAt if (patch.pinOrder !== undefined) update.pinOrder = patch.pinOrder update.modifiedAt = patch.modifiedAt ?? new Date() diff --git a/apps/core/src/modules/post/post.schema.ts b/apps/core/src/modules/post/post.schema.ts index a212d69f99f..89bc1ac8bef 100644 --- a/apps/core/src/modules/post/post.schema.ts +++ b/apps/core/src/modules/post/post.schema.ts @@ -38,6 +38,7 @@ const PostBaseSchema = WriteBaseSchema.extend({ ), relatedId: z.array(zEntityId).optional(), images: ImageArraySchema.optional(), + isPremium: z.boolean().optional(), /** ID of the associated draft; marked as published when this post is published */ draftId: zEntityId.optional(), }) diff --git a/apps/core/src/modules/post/post.service.ts b/apps/core/src/modules/post/post.service.ts index 7f8fe948b6e..514c6771a86 100644 --- a/apps/core/src/modules/post/post.service.ts +++ b/apps/core/src/modules/post/post.service.ts @@ -170,6 +170,11 @@ export class PostService implements OnApplicationBootstrap { async create(post: PostModel & { draftId?: string }) { this.lexicalService.normalizeContentForStorage(post) + const effectiveContentFormat = post.contentFormat ?? ContentFormat.Markdown + if (post.isPremium && effectiveContentFormat !== ContentFormat.Lexical) { + throw createAppException(AppErrorCode.PREMIUM_REQUIRES_LEXICAL) + } + const { categoryId, draftId } = post const category = await this.categoryService.findCategoryById( categoryId as any as string, @@ -200,6 +205,7 @@ export class PostService implements OnApplicationBootstrap { categoryId: category.id, copyright: post.copyright, isPublished: post.isPublished, + isPremium: post.isPremium, pinAt, pinOrder: post.pinOrder, }) @@ -338,6 +344,19 @@ export class PostService implements OnApplicationBootstrap { throw createAppException(AppErrorCode.POST_NOT_FOUND, { id }) } + const effectiveIsPremium = + data.isPremium !== undefined ? data.isPremium : oldDocument.isPremium + const effectiveContentFormat = + data.contentFormat !== undefined + ? data.contentFormat + : oldDocument.contentFormat + if ( + effectiveIsPremium && + effectiveContentFormat !== ContentFormat.Lexical + ) { + throw createAppException(AppErrorCode.PREMIUM_REQUIRES_LEXICAL) + } + const { draftId } = data const { categoryId } = data if (categoryId && String(categoryId) !== String(oldDocument.categoryId)) { @@ -393,6 +412,7 @@ export class PostService implements OnApplicationBootstrap { categoryId: patch.categoryId as string | undefined, copyright: patch.copyright, isPublished: patch.isPublished, + isPremium: patch.isPremium, pinAt, pinOrder: patch.pinOrder, modifiedAt: data.modifiedAt, diff --git a/apps/core/src/modules/post/post.types.ts b/apps/core/src/modules/post/post.types.ts index d2960a5175a..e26610b200c 100644 --- a/apps/core/src/modules/post/post.types.ts +++ b/apps/core/src/modules/post/post.types.ts @@ -21,6 +21,7 @@ export interface PostRow { } copyright: boolean isPublished: boolean + isPremium: boolean readCount: number likeCount: number pinAt: Date | null @@ -59,6 +60,7 @@ export interface PostCreateInput { categoryId: EntityId | string copyright?: boolean isPublished?: boolean + isPremium?: boolean pinAt?: Date | null pinOrder?: number | null } diff --git a/apps/core/test/mock/modules/entitlement.mock.ts b/apps/core/test/mock/modules/entitlement.mock.ts new file mode 100644 index 00000000000..b014942e0c0 --- /dev/null +++ b/apps/core/test/mock/modules/entitlement.mock.ts @@ -0,0 +1,12 @@ +import { defineProvider } from 'test/helper/defineProvider' + +import { EntitlementService } from '~/modules/membership/entitlement.service' + +export const entitlementProvider = defineProvider({ + provide: EntitlementService, + useValue: { + async isActiveMember() { + return false + }, + }, +}) diff --git a/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts index 4794edc742d..68d6f6827ad 100644 --- a/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts @@ -31,6 +31,7 @@ import { import { createE2EApp } from '../../../helper/create-e2e-app' import { authPassHeader } from '../../../mock/guard/auth.guard' import { enrichmentProvider } from '../../../mock/modules/enrichment.mock' +import { entitlementProvider } from '../../../mock/modules/entitlement.mock' import { snippetProvider } from '../../../mock/modules/snippet.mock' import { countingServiceProvider } from '../../../mock/processors/counting.mock' import { @@ -157,6 +158,7 @@ describe('PostController admin contract (e2e)', () => { aiInsightsProvider, aiSummaryProvider, snippetProvider, + entitlementProvider, ], }) diff --git a/apps/core/test/src/contracts/post.contract.spec.ts b/apps/core/test/src/contracts/post.contract.spec.ts index b2bf57a6f2e..bbe82f3931b 100644 --- a/apps/core/test/src/contracts/post.contract.spec.ts +++ b/apps/core/test/src/contracts/post.contract.spec.ts @@ -14,6 +14,7 @@ import { } from '../../helper/api-shape' import { createE2EApp } from '../../helper/create-e2e-app' import { enrichmentProvider } from '../../mock/modules/enrichment.mock' +import { entitlementProvider } from '../../mock/modules/entitlement.mock' import { snippetProvider } from '../../mock/modules/snippet.mock' import { countingServiceProvider } from '../../mock/processors/counting.mock' import { @@ -137,6 +138,7 @@ describe('PostController contract (e2e)', () => { aiInsightsProvider, aiSummaryProvider, snippetProvider, + entitlementProvider, ], }) diff --git a/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts index 15b10e97a1d..062553405b1 100644 --- a/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts @@ -33,6 +33,7 @@ import { } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' import { enrichmentProvider } from '../../../mock/modules/enrichment.mock' +import { entitlementProvider } from '../../../mock/modules/entitlement.mock' import { snippetProvider } from '../../../mock/modules/snippet.mock' import { countingServiceProvider } from '../../../mock/processors/counting.mock' import { @@ -134,6 +135,7 @@ describe('Yohaku contract — post detail (e2e)', () => { aiInsightsProvider, aiSummaryProvider, snippetProvider, + entitlementProvider, ], }) diff --git a/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts b/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts index e64e24cfe75..2369bd99a2b 100644 --- a/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts @@ -26,6 +26,7 @@ import { } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' import { enrichmentProvider } from '../../../mock/modules/enrichment.mock' +import { entitlementProvider } from '../../../mock/modules/entitlement.mock' import { snippetProvider } from '../../../mock/modules/snippet.mock' import { countingServiceProvider } from '../../../mock/processors/counting.mock' import { @@ -112,6 +113,7 @@ describe('Yohaku contract — post list (e2e)', () => { aiInsightsProvider, aiSummaryProvider, snippetProvider, + entitlementProvider, ], }) diff --git a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts new file mode 100644 index 00000000000..88a50aa2649 --- /dev/null +++ b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts @@ -0,0 +1,342 @@ +import * as schema from '@mx-space/db-schema/schema' +import type { ModuleMetadata } from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { createIsolatedPgDatabase } from 'test/helper/pg-testcontainer' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { RolesGuard } from '~/common/guards/roles.guard' +import { PG_DB_TOKEN } from '~/constants/system.constant' +import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' +import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { AuthService } from '~/modules/auth/auth.service' +import { ConfigsService } from '~/modules/configs/configs.service' +import { EnrichmentService } from '~/modules/enrichment/enrichment.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' +import { MembershipRepository } from '~/modules/membership/membership.repository' +import { PostController } from '~/modules/post/post.controller' +import { PostService } from '~/modules/post/post.service' +import { SnippetService } from '~/modules/snippet/snippet.service' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { CountingService } from '~/processors/helper/helper.counting.service' +import { TranslationService } from '~/processors/helper/helper.translation.service' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +import { createE2EApp } from '../../../helper/create-e2e-app' + +const snowflake = new SnowflakeService() + +const ownerReaderId = snowflake.nextId() +const nonMemberReaderId = snowflake.nextId() +const activeMemberReaderId = snowflake.nextId() +const onHoldMemberReaderId = snowflake.nextId() +const expiredMemberReaderId = snowflake.nextId() + +function textNode(text: string) { + return { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text, + type: 'text', + version: 1, + } +} + +function paragraph(text: string) { + return { + children: [textNode(text)], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + } +} + +function editorState(blocks: string[]) { + return JSON.stringify({ + root: { + children: blocks.map((text) => paragraph(text)), + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }) +} + +const FIVE_BLOCKS = ['one', 'two', 'three', 'four', 'five'] +const THREE_BLOCKS = ['alpha', 'beta', 'gamma'] + +const premiumPostFixture = { + id: '7100000000000000001', + title: 'Premium Post', + slug: 'premium-post', + text: FIVE_BLOCKS.join('\n\n'), + content: editorState(FIVE_BLOCKS), + contentFormat: 'lexical', + summary: null, + tags: [], + meta: null, + modifiedAt: null, + createdAt: new Date('2024-01-01'), + category: { id: '9100000000000000001', name: 'Tech', slug: 'tech', type: 1 }, + isPublished: true, + isPremium: true, + related: [], +} + +const boundaryPostFixture = { + ...premiumPostFixture, + id: '7100000000000000002', + slug: 'premium-boundary-post', + text: THREE_BLOCKS.join('\n\n'), + content: editorState(THREE_BLOCKS), +} + +const freePostFixture = { + ...premiumPostFixture, + id: '7100000000000000003', + slug: 'free-post', + isPremium: false, + contentFormat: 'markdown', + content: null, + text: 'plain markdown body', +} + +let currentPost: Record | null = premiumPostFixture + +const postService = { + findById: vi.fn(async () => currentPost), + getPostBySlug: vi.fn(async () => currentPost), + listPaginated: vi.fn(async () => ({ data: [], pagination: {} })), +} + +const readerRoleById: Record = + { + [ownerReaderId]: { id: ownerReaderId, role: 'owner' }, + [nonMemberReaderId]: { id: nonMemberReaderId, role: 'reader' }, + [activeMemberReaderId]: { id: activeMemberReaderId, role: 'reader' }, + [onHoldMemberReaderId]: { id: onHoldMemberReaderId, role: 'reader' }, + [expiredMemberReaderId]: { id: expiredMemberReaderId, role: 'reader' }, + } + +const authServiceMock = { + getSessionUser: vi.fn(async (req: { headers?: Record }) => { + const readerId = req?.headers?.['x-test-reader-id'] as string | undefined + if (!readerId) return null + const user = readerRoleById[readerId] + if (!user) return null + return { user, session: { token: `${readerId}-token` } } + }), + getApiKeyFromRequest: vi.fn(() => undefined), +} + +const postModule: ModuleMetadata = { + controllers: [PostController], + providers: [ + { provide: PostService, useValue: postService }, + { + provide: CountingService, + useValue: { getThisRecordIsLiked: vi.fn(async () => false) }, + }, + { + provide: TranslationService, + useValue: { + translateArticle: vi.fn(async () => ({ + isTranslated: false, + title: '', + text: '', + summary: null, + tags: [], + sourceLang: 'zh', + availableTranslations: [], + })), + getCachedTitles: vi.fn(async () => new Map()), + }, + }, + { + provide: AiInsightsService, + useValue: { hasInsightsInLang: vi.fn(async () => false) }, + }, + { + provide: AiSummaryService, + useValue: { getSummaryForPublicMeta: vi.fn(async () => null) }, + }, + { + provide: EnrichmentService, + useValue: { + attachEnrichments: vi.fn(async (doc: Record) => ({ + enrichments: {}, + ...doc, + })), + }, + }, + { + provide: TranslationEntryService, + useValue: { + getTranslationsBatch: vi.fn(async () => ({ + entityMaps: new Map(), + dictMaps: new Map(), + })), + }, + }, + { + provide: SnippetService, + useValue: { findSkillBundlesByIds: vi.fn(async () => []) }, + }, + EntitlementService, + MembershipRepository, + { provide: SnowflakeService, useValue: snowflake }, + { provide: AuthService, useValue: authServiceMock }, + { provide: ConfigsService, useValue: {} }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +} + +let pool: Pool +let db: Awaited> + +beforeAll(async () => { + db = await createIsolatedPgDatabase() + pool = new Pool({ connectionString: db.getConnectionUri(), max: 4 }) + const drizzleDb = drizzle(pool, { schema }) as unknown as AppDatabase + + postModule.providers!.push({ provide: PG_DB_TOKEN, useValue: drizzleDb }) + + await drizzleDb.insert(schema.readers).values([ + { id: ownerReaderId, name: 'Owner', role: 'owner' }, + { id: nonMemberReaderId, name: 'Non Member', role: 'reader' }, + { id: activeMemberReaderId, name: 'Active Member', role: 'reader' }, + { id: onHoldMemberReaderId, name: 'On Hold Member', role: 'reader' }, + { id: expiredMemberReaderId, name: 'Expired Member', role: 'reader' }, + ]) + + const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + const past = new Date(Date.now() - 24 * 60 * 60 * 1000) + + await drizzleDb.insert(schema.memberships).values([ + { + id: snowflake.nextId(), + readerId: activeMemberReaderId, + provider: 'manual', + plan: 'monthly', + status: 'active', + currentPeriodEnd: future, + }, + { + id: snowflake.nextId(), + readerId: onHoldMemberReaderId, + provider: 'manual', + plan: 'monthly', + status: 'on_hold', + currentPeriodEnd: future, + }, + { + id: snowflake.nextId(), + readerId: expiredMemberReaderId, + provider: 'manual', + plan: 'monthly', + status: 'active', + currentPeriodEnd: past, + }, + ]) +}, 120_000) + +afterAll(async () => { + await pool?.end() + await db?.drop() +}) + +const proxy = createE2EApp(postModule) + +const headerFor = (readerId?: string) => + readerId ? { 'x-test-reader-id': readerId } : {} + +describe('Post paywall enforcement (e2e)', () => { + describe('entitlement matrix on a premium lexical post', () => { + it.each([ + ['anonymous', undefined, true], + ['non-member reader', nonMemberReaderId, true], + ['active member', activeMemberReaderId, false], + ['on_hold member', onHoldMemberReaderId, false], + ['expired member', expiredMemberReaderId, true], + ['owner', ownerReaderId, false], + ])('%s -> locked=%s', async (_label, readerId, expectedLocked) => { + currentPost = { ...premiumPostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.category.slug}/${premiumPostFixture.slug}`, + headers: headerFor(readerId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(expectedLocked) + + if (expectedLocked) { + expect(body.meta.paywall.preview_blocks).toBe(3) + const truncatedContent = JSON.parse(body.data.content) + expect(truncatedContent.root.children).toHaveLength(3) + expect(body.data.text).toBe('one\n\ntwo\n\nthree') + } else { + expect(body.meta.paywall.preview_blocks).toBeUndefined() + const fullContent = JSON.parse(body.data.content) + expect(fullContent.root.children).toHaveLength(5) + expect(body.data.text).toBe(FIVE_BLOCKS.join('\n\n')) + } + }) + }) + + it('boundary: a post with exactly N blocks is still locked:true for a non-member', async () => { + currentPost = { ...boundaryPostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${boundaryPostFixture.category.slug}/${boundaryPostFixture.slug}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(true) + expect(body.meta.paywall.preview_blocks).toBe(3) + const truncatedContent = JSON.parse(body.data.content) + expect(truncatedContent.root.children).toHaveLength(3) + }) + + it('non-premium post carries no paywall meta', async () => { + currentPost = { ...freePostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${freePostFixture.category.slug}/${freePostFixture.slug}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall).toBeUndefined() + }) + + it('getById applies the same gate for a non-member reader', async () => { + currentPost = { ...premiumPostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.id}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(true) + expect(body.meta.paywall.preview_blocks).toBe(3) + }) +}) diff --git a/apps/core/test/src/modules/post/post-skill.e2e-spec.ts b/apps/core/test/src/modules/post/post-skill.e2e-spec.ts index b6c859d3865..edf2305938a 100644 --- a/apps/core/test/src/modules/post/post-skill.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-skill.e2e-spec.ts @@ -6,6 +6,7 @@ import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' import { EnrichmentService } from '~/modules/enrichment/enrichment.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { PostController } from '~/modules/post/post.controller' import { PostService } from '~/modules/post/post.service' import { SnippetService } from '~/modules/snippet/snippet.service' @@ -105,6 +106,10 @@ const proxy = createE2EApp({ }, }, { provide: SnippetService, useValue: snippetService }, + { + provide: EntitlementService, + useValue: { isActiveMember: vi.fn(async () => false) }, + }, ], }) diff --git a/apps/core/test/src/modules/post/post.controller.spec.ts b/apps/core/test/src/modules/post/post.controller.spec.ts index fcd3687193b..79a6cef22a4 100644 --- a/apps/core/test/src/modules/post/post.controller.spec.ts +++ b/apps/core/test/src/modules/post/post.controller.spec.ts @@ -110,6 +110,10 @@ const createController = (opts: CreateControllerOptions = {}) => { findSkillBundlesByIds: vi.fn(async () => []), } + const entitlementService = { + isActiveMember: vi.fn(async () => false), + } + const controller = new PostController( postService as any, countingService as any, @@ -119,6 +123,7 @@ const createController = (opts: CreateControllerOptions = {}) => { enrichmentService as any, translationEntryService as any, snippetService as any, + entitlementService as any, ) return { @@ -128,6 +133,7 @@ const createController = (opts: CreateControllerOptions = {}) => { enrichmentService, translationEntryService, snippetService, + entitlementService, } } diff --git a/apps/core/test/src/modules/post/post.service.spec.ts b/apps/core/test/src/modules/post/post.service.spec.ts index 59c4fc502d5..34d48007698 100644 --- a/apps/core/test/src/modules/post/post.service.spec.ts +++ b/apps/core/test/src/modules/post/post.service.spec.ts @@ -27,6 +27,7 @@ const createPost = (overrides: Partial = {}): PostRow => ({ category: null, copyright: true, isPublished: true, + isPremium: false, readCount: 0, likeCount: 0, pinAt: null, @@ -213,4 +214,87 @@ describe('PostService', () => { service.checkRelated({ relatedId: ['missing'] } as any), ).rejects.toThrow(AppException) }) + + it('rejects creating an isPremium markdown post with PREMIUM_REQUIRES_LEXICAL', async () => { + const { repository, service } = createService() + repository.findBySlug.mockResolvedValue(null) + + await expect( + service.create({ + title: 'Post', + text: 'body', + categoryId: 'cat-1', + contentFormat: ContentFormat.Markdown, + isPremium: true, + } as any), + ).rejects.toThrow(AppException) + + expect(repository.create).not.toHaveBeenCalled() + }) + + it('allows creating an isPremium lexical post', async () => { + const { repository, service } = createService() + repository.findBySlug.mockResolvedValue(null) + repository.create.mockResolvedValue( + createPost({ isPremium: true, contentFormat: ContentFormat.Lexical }), + ) + + await service.create({ + title: 'Post', + text: 'body', + content: '{}', + categoryId: 'cat-1', + contentFormat: ContentFormat.Lexical, + isPremium: true, + } as any) + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ isPremium: true }), + ) + }) + + it('rejects flipping isPremium true on an existing markdown post', async () => { + const { repository, service } = createService() + repository.findById.mockResolvedValue( + createPost({ contentFormat: ContentFormat.Markdown, isPremium: false }), + ) + + await expect( + service.updateById('post-1', { isPremium: true } as any), + ).rejects.toThrow(AppException) + + expect(repository.update).not.toHaveBeenCalled() + }) + + it('rejects flipping contentFormat away from lexical while isPremium stays true', async () => { + const { repository, service } = createService() + repository.findById.mockResolvedValue( + createPost({ contentFormat: ContentFormat.Lexical, isPremium: true }), + ) + + await expect( + service.updateById('post-1', { + contentFormat: ContentFormat.Markdown, + } as any), + ).rejects.toThrow(AppException) + + expect(repository.update).not.toHaveBeenCalled() + }) + + it('allows updating a lexical post to isPremium true', async () => { + const { repository, service } = createService() + repository.findById.mockResolvedValue( + createPost({ contentFormat: ContentFormat.Lexical, isPremium: false }), + ) + repository.update.mockResolvedValue( + createPost({ contentFormat: ContentFormat.Lexical, isPremium: true }), + ) + + await service.updateById('post-1', { isPremium: true } as any) + + expect(repository.update).toHaveBeenCalledWith( + 'post-1', + expect.objectContaining({ isPremium: true }), + ) + }) }) From 38eb917449ad2a09f0b2c86347dca6707a6b8899 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 19:59:16 +0800 Subject: [PATCH 07/37] fix(post): close paywall gate gaps in list route and previewBlocks clamp - GET /posts now forces a strict-subset teaser (content+text) on every isPremium row regardless of the client-controlled truncate query param or entitlement, closing a full-content leak on the list endpoint. - previewBlocks is now clamped to [0, blockCount-1] via a new resolveEffectivePreviewBlocks helper, so a short or misconfigured premium post can no longer serve full content while reporting locked:true. --- apps/core/src/modules/post/post.controller.ts | 22 ++++- apps/core/src/modules/post/post.repository.ts | 2 +- .../helper/lexical-truncate.util.ts | 17 ++++ .../src/modules/post/post-paywall.e2e-spec.ts | 99 ++++++++++++++++++- .../helper/lexical-truncate.util.spec.ts | 54 ++++++++++ 5 files changed, 186 insertions(+), 8 deletions(-) diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index feaf58ca3eb..f6f84dc9c38 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -39,6 +39,7 @@ import { } from '~/processors/helper/helper.translation.service' import { renderTeaserText, + resolveEffectivePreviewBlocks, truncateLexicalContent, } from '~/processors/helper/lexical-truncate.util' import { EntityIdDto } from '~/shared/dto/id.dto' @@ -102,12 +103,20 @@ export class PostController { return } - const previewBlocks = (doc.meta as any)?.paywall?.previewBlocks ?? 3 - doc.content = truncateLexicalContent(doc.content, previewBlocks) - doc.text = renderTeaserText(doc.content) + const previewBlocks = this.applyPaywallTeaser(doc) metaBuilder.paywall({ locked: true, previewBlocks }) } + private applyPaywallTeaser(doc: Record): number { + const effectiveN = resolveEffectivePreviewBlocks( + doc.content, + (doc.meta as any)?.paywall?.previewBlocks, + ) + doc.content = truncateLexicalContent(doc.content, effectiveN) + doc.text = renderTeaserText(doc.content) + return effectiveN + } + private async batchCategoryEntryTranslations( lang: string, posts: Array<{ category?: { id: unknown } | null } | null | undefined>, @@ -188,6 +197,13 @@ export class PostController { } for (const doc of res.data) { + if (doc.isPremium) { + if (typeof doc.content === 'string') { + this.applyPaywallTeaser(doc as Record) + } + continue + } + if (truncate) { doc.text = doc.text.slice(0, truncate) doc.content = null diff --git a/apps/core/src/modules/post/post.repository.ts b/apps/core/src/modules/post/post.repository.ts index 1f142a02438..b513ca2a69d 100644 --- a/apps/core/src/modules/post/post.repository.ts +++ b/apps/core/src/modules/post/post.repository.ts @@ -153,7 +153,7 @@ export class PostRepository extends BaseRepository { const selection = params.truncateText ? { - ...postMetaColumns, + ...postColumns, text: sql`left(${posts.text}, ${params.truncateText})`, } : postColumns diff --git a/apps/core/src/processors/helper/lexical-truncate.util.ts b/apps/core/src/processors/helper/lexical-truncate.util.ts index 5a4c5a61ae4..d2e7f7ddd51 100644 --- a/apps/core/src/processors/helper/lexical-truncate.util.ts +++ b/apps/core/src/processors/helper/lexical-truncate.util.ts @@ -41,3 +41,20 @@ export function renderTeaserText(truncatedJson: string): string { parseLexicalState(truncatedJson) return mxLexicalToMarkdown(truncatedJson) } + +export function countTopLevelBlocks(contentJson: string): number { + return parseLexicalState(contentJson).root.children.length +} + +export function resolveEffectivePreviewBlocks( + contentJson: string, + configuredPreviewBlocks: unknown, +): number { + const blockCount = countTopLevelBlocks(contentJson) + const configured = + typeof configuredPreviewBlocks === 'number' && + Number.isFinite(configuredPreviewBlocks) + ? Math.max(1, Math.floor(configuredPreviewBlocks)) + : 3 + return Math.max(0, Math.min(configured, blockCount - 1)) +} diff --git a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts index 88a50aa2649..1d2187c0744 100644 --- a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts @@ -72,6 +72,7 @@ function editorState(blocks: string[]) { const FIVE_BLOCKS = ['one', 'two', 'three', 'four', 'five'] const THREE_BLOCKS = ['alpha', 'beta', 'gamma'] +const ONE_BLOCK = ['solo'] const premiumPostFixture = { id: '7100000000000000001', @@ -99,6 +100,14 @@ const boundaryPostFixture = { content: editorState(THREE_BLOCKS), } +const oneBlockPostFixture = { + ...premiumPostFixture, + id: '7100000000000000004', + slug: 'premium-one-block-post', + text: ONE_BLOCK.join('\n\n'), + content: editorState(ONE_BLOCK), +} + const freePostFixture = { ...premiumPostFixture, id: '7100000000000000003', @@ -110,11 +119,20 @@ const freePostFixture = { } let currentPost: Record | null = premiumPostFixture +let currentListData: Record[] = [] const postService = { findById: vi.fn(async () => currentPost), getPostBySlug: vi.fn(async () => currentPost), - listPaginated: vi.fn(async () => ({ data: [], pagination: {} })), + listPaginated: vi.fn(async () => ({ + data: currentListData.map((doc) => ({ ...doc })), + pagination: { + currentPage: 1, + size: currentListData.length, + total: currentListData.length, + totalPage: 1, + }, + })), } const readerRoleById: Record = @@ -157,6 +175,10 @@ const postModule: ModuleMetadata = { sourceLang: 'zh', availableTranslations: [], })), + collectArticleTranslations: vi.fn(async () => ({ + results: new Map(), + meta: new Map(), + })), getCachedTitles: vi.fn(async () => new Map()), }, }, @@ -294,7 +316,7 @@ describe('Post paywall enforcement (e2e)', () => { }) }) - it('boundary: a post with exactly N blocks is still locked:true for a non-member', async () => { + it('boundary: a post with exactly N=3 blocks is a strict subset (2 blocks, locked:true) for a non-member', async () => { currentPost = { ...boundaryPostFixture } const res = await proxy.app.inject({ @@ -306,9 +328,28 @@ describe('Post paywall enforcement (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() expect(body.meta.paywall.locked).toBe(true) - expect(body.meta.paywall.preview_blocks).toBe(3) + expect(body.meta.paywall.preview_blocks).toBe(2) + const truncatedContent = JSON.parse(body.data.content) + expect(truncatedContent.root.children).toHaveLength(2) + expect(body.data.text).toBe('alpha\n\nbeta') + }) + + it('a 1-block premium post yields an empty preview, still locked:true for a non-member', async () => { + currentPost = { ...oneBlockPostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${oneBlockPostFixture.category.slug}/${oneBlockPostFixture.slug}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(true) + expect(body.meta.paywall.preview_blocks).toBe(0) const truncatedContent = JSON.parse(body.data.content) - expect(truncatedContent.root.children).toHaveLength(3) + expect(truncatedContent.root.children).toHaveLength(0) + expect(body.data.text).toBe('') }) it('non-premium post carries no paywall meta', async () => { @@ -339,4 +380,54 @@ describe('Post paywall enforcement (e2e)', () => { expect(body.meta.paywall.locked).toBe(true) expect(body.meta.paywall.preview_blocks).toBe(3) }) + + describe('GET /posts list route always teasers premium rows', () => { + it('without ?truncate: premium row content/text are teasers, non-premium row is untouched', async () => { + currentListData = [{ ...premiumPostFixture }, { ...freePostFixture }] + + const res = await proxy.app.inject({ method: 'GET', url: '/posts' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + const premiumRow = body.data.find( + (row: any) => row.id === premiumPostFixture.id, + ) + const freeRow = body.data.find( + (row: any) => row.id === freePostFixture.id, + ) + + const truncatedContent = JSON.parse(premiumRow.content) + expect(truncatedContent.root.children).toHaveLength(3) + expect(premiumRow.text).toBe('one\n\ntwo\n\nthree') + + expect(freeRow.content).toBe(freePostFixture.content) + expect(freeRow.text).toBe(freePostFixture.text) + }) + + it('with ?truncate: premium row is still a teaser (not the client-controlled slice), non-premium row is nulled/sliced as before', async () => { + currentListData = [{ ...premiumPostFixture }, { ...freePostFixture }] + + const res = await proxy.app.inject({ + method: 'GET', + url: '/posts?truncate=500', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + const premiumRow = body.data.find( + (row: any) => row.id === premiumPostFixture.id, + ) + const freeRow = body.data.find( + (row: any) => row.id === freePostFixture.id, + ) + + const truncatedContent = JSON.parse(premiumRow.content) + expect(truncatedContent.root.children).toHaveLength(3) + expect(premiumRow.text).toBe('one\n\ntwo\n\nthree') + expect(premiumRow.text).not.toBe(premiumPostFixture.text) + + expect(freeRow.content).toBeNull() + expect(freeRow.text).toBe(freePostFixture.text.slice(0, 500)) + }) + }) }) diff --git a/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts b/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts index f22091f161a..01f1870495e 100644 --- a/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts +++ b/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + countTopLevelBlocks, renderTeaserText, + resolveEffectivePreviewBlocks, truncateLexicalContent, } from '~/processors/helper/lexical-truncate.util' @@ -112,3 +114,55 @@ describe('renderTeaserText', () => { expect(() => renderTeaserText('not json')).toThrow() }) }) + +describe('countTopLevelBlocks', () => { + it('counts root.children length', () => { + const content = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + paragraph(textNode('three')), + ]) + + expect(countTopLevelBlocks(content)).toBe(3) + }) +}) + +describe('resolveEffectivePreviewBlocks', () => { + const threeBlockContent = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + paragraph(textNode('three')), + ]) + + it('clamps configured N to blockCount - 1', () => { + expect(resolveEffectivePreviewBlocks(threeBlockContent, 3)).toBe(2) + expect(resolveEffectivePreviewBlocks(threeBlockContent, 100)).toBe(2) + }) + + it('defaults to 3 when unconfigured', () => { + const fiveBlockContent = makeEditorState([ + paragraph(textNode('a')), + paragraph(textNode('b')), + paragraph(textNode('c')), + paragraph(textNode('d')), + paragraph(textNode('e')), + ]) + + expect(resolveEffectivePreviewBlocks(fiveBlockContent, undefined)).toBe(3) + }) + + it('clamps configured N below 1 up to 1', () => { + expect(resolveEffectivePreviewBlocks(threeBlockContent, 0)).toBe(1) + expect(resolveEffectivePreviewBlocks(threeBlockContent, -5)).toBe(1) + }) + + it('floors at 0 for a single-block post', () => { + const oneBlockContent = makeEditorState([paragraph(textNode('solo'))]) + + expect(resolveEffectivePreviewBlocks(oneBlockContent, 3)).toBe(0) + }) + + it('ignores non-numeric configured values and falls back to default', () => { + expect(resolveEffectivePreviewBlocks(threeBlockContent, 'nope')).toBe(2) + }) +}) From e097f99d1b69167d0689d2785904f316d3e0f734 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 20:13:51 +0800 Subject: [PATCH 08/37] feat(post): route public surfaces through getPublicText/getPublicContent for premium posts Close the remaining premium-content leak surfaces: RSS/feed JSON export (AggregateService.buildRssStructure), the search index (post/translation documents), and gateway/websocket post create/update broadcasts now emit a teaser instead of the full body for premium posts. Also fail-closed the getPaginate list-path hardening gap: a premium row whose content isn't a string now serves an empty teaser instead of leaking full text. --- .../modules/aggregate/aggregate.service.ts | 8 +- apps/core/src/modules/post/post.controller.ts | 2 + .../core/src/modules/search/search.service.ts | 41 +++++++++- .../web/visitor-event-dispatch.service.ts | 22 ++++- .../helper/lexical-truncate.util.ts | 39 +++++++++ .../aggregate/aggregate.service.spec.ts | 81 +++++++++++++++++++ .../src/modules/post/post.controller.spec.ts | 15 ++++ .../src/modules/search/search.service.spec.ts | 66 +++++++++++++++ .../visitor-event-dispatch.service.spec.ts | 69 ++++++++++++++++ .../helper/lexical-truncate.util.spec.ts | 72 +++++++++++++++++ 10 files changed, 407 insertions(+), 8 deletions(-) diff --git a/apps/core/src/modules/aggregate/aggregate.service.ts b/apps/core/src/modules/aggregate/aggregate.service.ts index f1892cd6d78..72d4785ad44 100644 --- a/apps/core/src/modules/aggregate/aggregate.service.ts +++ b/apps/core/src/modules/aggregate/aggregate.service.ts @@ -11,6 +11,10 @@ import { } from '~/constants/injection.constant' import { WebEventsGateway } from '~/processors/gateway/web/events.gateway' import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service' +import { + getPublicContent, + getPublicText, +} from '~/processors/helper/lexical-truncate.util' import { RedisService } from '~/processors/redis/redis.service' import { getRedisKey } from '~/utils/redis.util' import { getShortDate } from '~/utils/time.util' @@ -213,11 +217,11 @@ export class AggregateService { modified: item.modifiedAt ?? null, link: baseURL + this.urlBuilder.build(item as any), title: item.title ?? '', - text: item.text ?? '', + text: getPublicText(item as any), id: item.id, images: item.images ?? [], contentFormat: item.contentFormat, - content: item.content, + content: getPublicContent(item as any) ?? undefined, })), } } diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index f6f84dc9c38..46acaa0ba81 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -200,6 +200,8 @@ export class PostController { if (doc.isPremium) { if (typeof doc.content === 'string') { this.applyPaywallTeaser(doc as Record) + } else { + doc.text = '' } continue } diff --git a/apps/core/src/modules/search/search.service.ts b/apps/core/src/modules/search/search.service.ts index e0a07351f54..643d8829f4e 100644 --- a/apps/core/src/modules/search/search.service.ts +++ b/apps/core/src/modules/search/search.service.ts @@ -9,6 +9,10 @@ import type { AiTranslationRow } from '~/modules/ai/ai-translation/ai-translatio import type { SearchDto } from '~/modules/search/search.schema' import type { PaginationResult } from '~/processors/database/base.repository' import { paginationOf } from '~/processors/database/base.repository' +import { + getPublicContent, + getPublicText, +} from '~/processors/helper/lexical-truncate.util' import { AsyncQueue } from '~/utils/queue.util' import { NoteService } from '../note/note.service' @@ -114,6 +118,15 @@ export class SearchService { private readonly aiTranslationRepository: AiTranslationRepository, ) {} + private toPublicArticleFields>(article: T): T { + if (!article?.isPremium) return article + return { + ...article, + text: getPublicText(article), + content: getPublicContent(article), + } + } + async search(searchOption: SearchDto) { return this.searchIndex(searchOption, undefined) } @@ -236,7 +249,13 @@ export class SearchService { for (const article of articles) { const lang = sourceLangs.get(article.id) ?? SEARCH_DOCUMENT_DEFAULT_SOURCE_LANG - docs.push(buildSearchDocument(refType, article as any, lang)) + docs.push( + buildSearchDocument( + refType, + this.toPublicArticleFields(article as any), + lang, + ), + ) articleCache.set(`${refType}:${article.id}`, { article, refType }) } } @@ -385,7 +404,11 @@ export class SearchService { let rebuilt = 0 const sourceLang = await this.resolveSourceLang(refType, refId) await this.searchRepository.upsert( - buildSearchDocument(refType, article as any, sourceLang) as any, + buildSearchDocument( + refType, + this.toPublicArticleFields(article as any), + sourceLang, + ) as any, ) rebuilt++ @@ -413,7 +436,11 @@ export class SearchService { const sourceLang = await this.resolveSourceLang(refType, id) await this.searchRepository.upsert( - buildSearchDocument(refType, sourceDocument as any, sourceLang) as any, + buildSearchDocument( + refType, + this.toPublicArticleFields(sourceDocument as any), + sourceLang, + ) as any, ) } @@ -451,6 +478,8 @@ export class SearchService { text: translation.text, contentFormat: translation.contentFormat, content: translation.content, + isPremium: article.isPremium, + meta: article.meta, tags: translation.tags ?? [], slug: article.slug ?? null, nid: article.nid ?? null, @@ -462,7 +491,11 @@ export class SearchService { modifiedAt: article.modifiedAt ?? null, sourceHash: computeTranslationSourceHash(translation), } - return buildSearchDocument(refType, merged, translation.lang) + return buildSearchDocument( + refType, + this.toPublicArticleFields(merged), + translation.lang, + ) } // ───────────────────────────────────────────────── source lang resolve ── diff --git a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts index c9672ce325e..93b006af481 100644 --- a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts +++ b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts @@ -21,6 +21,10 @@ import { type EntryRule, TranslationService, } from '~/processors/helper/helper.translation.service' +import { + getPublicContent, + getPublicText, +} from '~/processors/helper/lexical-truncate.util' import { GatewayService } from '../gateway.service' import { WebEventsGateway } from './events.gateway' @@ -108,6 +112,15 @@ export class VisitorEventDispatchService implements OnModuleInit { // --- Post --- + private toPublicPostPayload>(doc: T): T { + if (!doc?.isPremium) return doc + return { + ...doc, + text: getPublicText(doc), + content: getPublicContent(doc), + } + } + @OnVisitorEvent(BusinessEvents.POST_CREATE) async onPostCreate(payload: { id: string }) { const doc = await this.enricher.enrichPayload( @@ -115,7 +128,10 @@ export class VisitorEventDispatchService implements OnModuleInit { payload, ) if (!doc || doc === payload) return - this.webGateway.broadcast(BusinessEvents.POST_CREATE, doc) + this.webGateway.broadcast( + BusinessEvents.POST_CREATE, + this.toPublicPostPayload(doc), + ) } @OnVisitorEvent(BusinessEvents.POST_UPDATE) @@ -392,7 +408,9 @@ export class VisitorEventDispatchService implements OnModuleInit { } // The socket ID is the room socket.io auto-joins, so we can target it directly - this.webGateway.broadcast(event, data, { rooms: socketIds }) + this.webGateway.broadcast(event, this.toPublicPostPayload(data), { + rooms: socketIds, + }) } } diff --git a/apps/core/src/processors/helper/lexical-truncate.util.ts b/apps/core/src/processors/helper/lexical-truncate.util.ts index d2e7f7ddd51..91645142694 100644 --- a/apps/core/src/processors/helper/lexical-truncate.util.ts +++ b/apps/core/src/processors/helper/lexical-truncate.util.ts @@ -58,3 +58,42 @@ export function resolveEffectivePreviewBlocks( : 3 return Math.max(0, Math.min(configured, blockCount - 1)) } + +interface PublicPostSource { + isPremium?: boolean | null + text?: string | null + content?: string | null + meta?: { paywall?: { previewBlocks?: unknown } } | null +} + +function truncatedContentOf(post: PublicPostSource): string | null { + if (typeof post.content !== 'string' || !post.content) return null + try { + const effectiveN = resolveEffectivePreviewBlocks( + post.content, + post.meta?.paywall?.previewBlocks, + ) + return truncateLexicalContent(post.content, effectiveN) + } catch { + return null + } +} + +export function getPublicText(post: PublicPostSource): string { + if (!post.isPremium) return post.text ?? '' + + const truncated = truncatedContentOf(post) + if (!truncated) return '' + try { + return renderTeaserText(truncated) + } catch { + return '' + } +} + +export function getPublicContent(post: PublicPostSource): string | null { + if (!post.isPremium) { + return typeof post.content === 'string' ? post.content : null + } + return truncatedContentOf(post) +} diff --git a/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts b/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts index b1e60f48448..7562ce00330 100644 --- a/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts +++ b/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts @@ -89,3 +89,84 @@ describe('AggregateService.topActivity', () => { }) }) }) + +describe('AggregateService.buildRssStructure', () => { + const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }) + + it('emits a teaser, not the full body, for a premium post', async () => { + const premiumPost = { + ...postRow, + isPremium: true, + text: 'full premium body', + content: premiumContent, + } + const postService = { findRecent: vi.fn(async () => [premiumPost]) } + const noteService = { findRecent: vi.fn(async () => []) } + const ownerService = { getOwner: vi.fn(async () => ({ name: 'Owner' })) } + const configs = { + get: vi.fn(async (key: string) => + key === 'seo' + ? { title: 'Blog', description: 'desc' } + : { webUrl: 'https://example.com' }, + ), + } + const urlBuilder = { build: vi.fn(() => '/tech/a-post') } + + const service = new AggregateService( + postService as any, + noteService as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ownerService as any, + configs as any, + {} as any, + {} as any, + {} as any, + urlBuilder as any, + ) + + const { data } = await service.buildRssStructure() + + expect(data).toHaveLength(1) + expect(data[0].text).not.toContain('full premium body') + expect(JSON.parse(data[0].content!).root.children).toHaveLength(2) + }) +}) diff --git a/apps/core/test/src/modules/post/post.controller.spec.ts b/apps/core/test/src/modules/post/post.controller.spec.ts index 79a6cef22a4..87e1e032ba3 100644 --- a/apps/core/test/src/modules/post/post.controller.spec.ts +++ b/apps/core/test/src/modules/post/post.controller.spec.ts @@ -289,6 +289,21 @@ describe('PostController.getPaginate', () => { expect(res.data[0].title).toBe('A translated') expect(res.data[1].title).toBe('B') }) + + it('fails closed to an empty teaser for a premium post whose content is not a string', async () => { + const post = makePost({ + isPremium: true, + content: null, + text: 'full premium body leaked', + }) + + const { controller } = createController({ posts: [post] }) + + const res = await controller.getPaginate({} as any, false) + + expect(res.data[0].text).toBe('') + expect(res.data[0].content).toBeFalsy() + }) }) describe('PostController.getByCateAndSlug', () => { diff --git a/apps/core/test/src/modules/search/search.service.spec.ts b/apps/core/test/src/modules/search/search.service.spec.ts index a1317ea07b5..a9fe0b9c418 100644 --- a/apps/core/test/src/modules/search/search.service.spec.ts +++ b/apps/core/test/src/modules/search/search.service.spec.ts @@ -198,6 +198,72 @@ describe('SearchService', () => { ) }) + it('indexes only the teaser body for a premium post', async () => { + const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + direction: 'ltr', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'text', + text: 'visible teaser', + detail: 0, + format: 0, + mode: 'normal', + style: '', + version: 1, + }, + ], + }, + { + type: 'paragraph', + direction: 'ltr', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'text', + text: 'secret paywalled body', + detail: 0, + format: 0, + mode: 'normal', + style: '', + version: 1, + }, + ], + }, + ], + }, + }) + const premiumArticle = { + ...baseArticle, + isPremium: true, + contentFormat: ContentFormat.Lexical, + text: 'secret paywalled body full text', + content: premiumContent, + meta: null, + } + const { service, searchRepo } = makeService({ + postFindById: premiumArticle, + }) + + await service.onPostCreate({ id: 'post-1' }) + + expect(searchRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchText: expect.stringContaining('visible teaser'), + }), + ) + const upserted = searchRepo.upsert.mock.calls[0][0] + expect(upserted.searchText).not.toContain('secret') + }) + it('defaults source lang to zh when nothing else resolves', async () => { const { service, searchRepo } = makeService({}) await service.onPostCreate({ id: 'post-1' }) diff --git a/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts b/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts index f35f8fa62bb..73a6beb8a72 100644 --- a/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts +++ b/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts @@ -148,3 +148,72 @@ describe('VisitorEventDispatchService socket localization', () => { }) }) }) + +describe('VisitorEventDispatchService premium paywall', () => { + const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + direction: 'ltr', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'text', + text: 'visible teaser', + detail: 0, + format: 0, + mode: 'normal', + style: '', + version: 1, + }, + ], + }, + { + type: 'paragraph', + direction: 'ltr', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'text', + text: 'secret paywalled body', + detail: 0, + format: 0, + mode: 'normal', + style: '', + version: 1, + }, + ], + }, + ], + }, + }) + + it('broadcasts a teaser, not the full body, for a premium post update', async () => { + const { broadcasts, service } = createService() + + await (service as any).broadcastWithTranslation( + BusinessEvents.POST_UPDATE, + { + id: 'post-1', + isPremium: true, + text: 'secret paywalled body full text', + content: premiumContent, + meta: null, + title: '中文标题', + }, + 'article-post-1', + ) + + const jaPayload = broadcasts.find((item) => + item.rooms?.includes('socket-ja'), + )?.data + + expect(jaPayload.text).not.toContain('secret') + expect(JSON.parse(jaPayload.content).root.children).toHaveLength(1) + }) +}) diff --git a/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts b/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts index 01f1870495e..7f1e6379f25 100644 --- a/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts +++ b/apps/core/test/src/processors/helper/lexical-truncate.util.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { countTopLevelBlocks, + getPublicContent, + getPublicText, renderTeaserText, resolveEffectivePreviewBlocks, truncateLexicalContent, @@ -166,3 +168,73 @@ describe('resolveEffectivePreviewBlocks', () => { expect(resolveEffectivePreviewBlocks(threeBlockContent, 'nope')).toBe(2) }) }) + +describe('getPublicText', () => { + const threeBlockContent = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + paragraph(textNode('three')), + ]) + + it('returns full text for a non-premium post', () => { + expect( + getPublicText({ isPremium: false, text: 'full text', content: null }), + ).toBe('full text') + }) + + it('returns the teaser for a premium post', () => { + expect( + getPublicText({ + isPremium: true, + text: 'full text', + content: threeBlockContent, + }), + ).toBe('one\n\ntwo') + }) + + it('fails closed to an empty string when premium content is not a lexical JSON string', () => { + expect( + getPublicText({ isPremium: true, text: 'full text', content: null }), + ).toBe('') + expect( + getPublicText({ + isPremium: true, + text: 'full text', + content: 'not json', + }), + ).toBe('') + }) +}) + +describe('getPublicContent', () => { + const threeBlockContent = makeEditorState([ + paragraph(textNode('one')), + paragraph(textNode('two')), + paragraph(textNode('three')), + ]) + + it('returns full content for a non-premium post', () => { + expect( + getPublicContent({ + isPremium: false, + text: '', + content: threeBlockContent, + }), + ).toBe(threeBlockContent) + }) + + it('returns the truncated content for a premium post', () => { + const truncated = getPublicContent({ + isPremium: true, + text: '', + content: threeBlockContent, + }) + expect(JSON.parse(truncated!).root.children).toHaveLength(2) + }) + + it('fails closed to null when premium content is not a lexical JSON string', () => { + expect( + getPublicContent({ isPremium: true, text: '', content: null }), + ).toBeNull() + }) +}) From 4d07d68c9d73f1ef675d715274b8d31abc1ff758 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 20:23:28 +0800 Subject: [PATCH 09/37] fix(render): gate premium content behind teaser on public render routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /render/markdown/:id and GET /markdown/render/structure/:id were unauthenticated and rendered document.text unconditionally, leaking the full body of premium posts. MarkdownService.renderArticle now defaults to getPublicText(document) (teaser for premium posts) and only returns the full body when the caller explicitly passes { asOwner: true } — RenderEjsController wires this from RequestContext.hasAdminAccess(), resolved before rendering since RolesGuard runs globally. Also add a regression test pinning that FeedController.rss never emits full body text for a premium/lexical post, since that branch's safety today rests on the premium-implies-lexical invariant rather than routing through the accessor. --- .../src/modules/markdown/markdown.service.ts | 9 +- .../src/modules/render/render.controller.ts | 2 +- .../src/modules/feed/feed.controller.spec.ts | 54 ++++++++ .../markdown/markdown.controller.spec.ts | 26 ++++ .../modules/markdown/markdown.service.spec.ts | 98 ++++++++++++++- .../modules/render/render.controller.spec.ts | 116 ++++++++++++++++++ 6 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 apps/core/test/src/modules/feed/feed.controller.spec.ts create mode 100644 apps/core/test/src/modules/markdown/markdown.controller.spec.ts create mode 100644 apps/core/test/src/modules/render/render.controller.spec.ts diff --git a/apps/core/src/modules/markdown/markdown.service.ts b/apps/core/src/modules/markdown/markdown.service.ts index 9e3685150d4..e2afd0a788a 100644 --- a/apps/core/src/modules/markdown/markdown.service.ts +++ b/apps/core/src/modules/markdown/markdown.service.ts @@ -12,6 +12,7 @@ import { AppErrorCode, createAppException } from '~/common/errors' import { CollectionRefTypes } from '~/constants/db.constant' import { DatabaseService } from '~/processors/database/database.service' import { AssetService } from '~/processors/helper/helper.asset.service' +import { getPublicText } from '~/processors/helper/lexical-truncate.util' import { ContentFormat } from '~/shared/types/content-format.type' import { CategoryService } from '../category/category.service' @@ -212,14 +213,18 @@ ${text.trim()} * @param id * @returns */ - async renderArticle(id: string) { + async renderArticle(id: string, options: { asOwner?: boolean } = {}) { const result = await this.databaseService.findGlobalById(id) if (!result || result.type === CollectionRefTypes.Recently) throw createAppException(AppErrorCode.DOCUMENT_NOT_FOUND, { id }) + const text = options.asOwner + ? result.document.text + : getPublicText(result.document) + return { - html: this.renderMarkdownContent(result.document.text), + html: this.renderMarkdownContent(text), ...result, document: result.document, } diff --git a/apps/core/src/modules/render/render.controller.ts b/apps/core/src/modules/render/render.controller.ts index c221107f964..c972637ef63 100644 --- a/apps/core/src/modules/render/render.controller.ts +++ b/apps/core/src/modules/render/render.controller.ts @@ -56,7 +56,7 @@ export class RenderEjsController { }, { name: username }, ] = await Promise.all([ - this.service.renderArticle(id), + this.service.renderArticle(id, { asOwner: hasAdminAccess }), this.configs.waitForConfigReady(), this.ownerService.getOwner(), ]) diff --git a/apps/core/test/src/modules/feed/feed.controller.spec.ts b/apps/core/test/src/modules/feed/feed.controller.spec.ts new file mode 100644 index 00000000000..bf146f7ca85 --- /dev/null +++ b/apps/core/test/src/modules/feed/feed.controller.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +import { FeedController } from '~/modules/feed/feed.controller' +import { ContentFormat } from '~/shared/types/content-format.type' + +describe('FeedController.rss', () => { + it('never emits full body text for a premium (lexical) post', async () => { + const premiumItem = { + id: 'post-1', + title: 'Premium Post', + link: 'https://example.com/posts/tech/premium-post', + created: new Date(), + text: 'full premium body', + content: 'full premium content', + contentFormat: ContentFormat.Lexical, + images: [], + } + const aggregateService = { + buildRssStructure: vi.fn().mockResolvedValue({ + author: 'Owner', + description: 'desc', + url: 'https://example.com', + data: [premiumItem], + }), + } + const configs = { + get: vi.fn(async (key: string) => + key === 'seo' + ? { title: 'Blog', description: 'desc' } + : { webUrl: 'https://example.com' }, + ), + } + const ownerService = { getOwner: vi.fn().mockResolvedValue({ avatar: '' }) } + const markdownService = { + renderArticle: vi.fn().mockResolvedValue({ + html: '

full premium body rendered

', + type: 'Post', + document: { text: 'full premium body', category: { name: 'Tech' } }, + }), + } + + const controller = new FeedController( + aggregateService as any, + configs as any, + ownerService as any, + markdownService as any, + ) + + const xml = await controller.rss() + + expect(xml).not.toContain('full premium body') + expect(xml).not.toContain('full premium content') + }) +}) diff --git a/apps/core/test/src/modules/markdown/markdown.controller.spec.ts b/apps/core/test/src/modules/markdown/markdown.controller.spec.ts new file mode 100644 index 00000000000..17938281ea3 --- /dev/null +++ b/apps/core/test/src/modules/markdown/markdown.controller.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +import { MarkdownController } from '~/modules/markdown/markdown.controller' + +describe('MarkdownController.getRenderedMarkdownHtmlStructure', () => { + it('never passes asOwner, so premium content stays teased regardless of caller identity', async () => { + const service = { + renderArticle: vi.fn().mockResolvedValue({ + html: '

teaser

', + document: { title: 'Premium Post' }, + }), + getRenderedMarkdownHtmlStructure: vi + .fn() + .mockResolvedValue({ body: ['

teaser

'] }), + } + const controller = new MarkdownController(service as any) + + await controller.getRenderedMarkdownHtmlStructure({ id: 'post-1' } as any) + + expect(service.renderArticle).toHaveBeenCalledWith('post-1') + expect(service.renderArticle).not.toHaveBeenCalledWith( + 'post-1', + expect.objectContaining({ asOwner: true }), + ) + }) +}) diff --git a/apps/core/test/src/modules/markdown/markdown.service.spec.ts b/apps/core/test/src/modules/markdown/markdown.service.spec.ts index 9a097ad1b60..bbc306afdac 100644 --- a/apps/core/test/src/modules/markdown/markdown.service.spec.ts +++ b/apps/core/test/src/modules/markdown/markdown.service.spec.ts @@ -1,9 +1,46 @@ import { describe, expect, it, vi } from 'vitest' +import { CollectionRefTypes } from '~/constants/db.constant' import { MarkdownService } from '~/modules/markdown/markdown.service' import { ContentFormat } from '~/shared/types/content-format.type' -const createService = () => { +const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, +}) + +const createService = ({ findGlobalById }: { findGlobalById?: any } = {}) => { const assetService = {} const categoryService = { findAllCategory: vi @@ -18,7 +55,7 @@ const createService = () => { create: vi.fn(async (note) => ({ id: 'note-1', ...note })), } const pageService = {} - const databaseService = {} + const databaseService = { findGlobalById } const service = new MarkdownService( assetService as any, categoryService as any, @@ -66,3 +103,60 @@ describe('MarkdownService', () => { ) }) }) + +describe('MarkdownService.renderArticle', () => { + const premiumPost = { + id: 'post-1', + title: 'Premium Post', + isPremium: true, + text: 'full premium body', + content: premiumContent, + } + const freePost = { + id: 'post-2', + title: 'Free Post', + isPremium: false, + text: 'full free body', + content: premiumContent, + } + + it('renders only the teaser for a premium post by default', async () => { + const findGlobalById = vi.fn(async () => ({ + document: premiumPost, + type: CollectionRefTypes.Post, + })) + const { service } = createService({ findGlobalById }) + + const { html } = await service.renderArticle('post-1') + + expect(html).not.toContain('full premium body') + }) + + it('renders the full body for a premium post when asOwner is true', async () => { + const findGlobalById = vi.fn(async () => ({ + document: premiumPost, + type: CollectionRefTypes.Post, + })) + const { service } = createService({ findGlobalById }) + + const { html } = await service.renderArticle('post-1', { asOwner: true }) + + expect(html).toContain('full premium body') + }) + + it('renders the full body for a free post regardless of asOwner', async () => { + const findGlobalById = vi.fn(async () => ({ + document: freePost, + type: CollectionRefTypes.Post, + })) + const { service } = createService({ findGlobalById }) + + const { html: teaserHtml } = await service.renderArticle('post-2') + const { html: ownerHtml } = await service.renderArticle('post-2', { + asOwner: true, + }) + + expect(teaserHtml).toContain('full free body') + expect(ownerHtml).toContain('full free body') + }) +}) diff --git a/apps/core/test/src/modules/render/render.controller.spec.ts b/apps/core/test/src/modules/render/render.controller.spec.ts new file mode 100644 index 00000000000..f4cca5700e1 --- /dev/null +++ b/apps/core/test/src/modules/render/render.controller.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' + +import { RequestContext } from '~/common/contexts/request.context' +import { CollectionRefTypes } from '~/constants/db.constant' +import { RenderEjsController } from '~/modules/render/render.controller' + +const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, +}) + +const premiumPost = { + id: 'post-1', + title: 'Premium Post', + isPublished: true, + isPremium: true, + password: null, + text: 'full premium body', + content: premiumContent, + createdAt: new Date(), + slug: 'premium-post', + category: { slug: 'tech' }, +} + +const createController = () => { + const markdownService = { + renderArticle: vi.fn( + async (_id: string, options?: { asOwner?: boolean }) => ({ + html: options?.asOwner ? `

${premiumPost.text}

` : '

', + document: premiumPost, + type: CollectionRefTypes.Post, + }), + ), + getRenderedMarkdownHtmlStructure: vi + .fn() + .mockResolvedValue({ body: [], style: [] }), + getMarkdownEjsRenderTemplate: vi.fn().mockResolvedValue('<%- body %>'), + } + const configs = { + waitForConfigReady: vi + .fn() + .mockResolvedValue({ url: { webUrl: 'https://example.com' } }), + } + const ownerService = { + getOwner: vi.fn().mockResolvedValue({ name: 'Owner' }), + } + const controller = new RenderEjsController( + markdownService as any, + configs as any, + ownerService as any, + ) + return { controller, markdownService } +} + +const runWithAdminAccess = (hasAdminAccess: boolean, fn: () => Promise) => + RequestContext.run( + new RequestContext({ hasAdminAccess } as any, {} as any), + fn, + ) + +describe('RenderEjsController.renderArticle', () => { + it('requests the teaser for an unauthenticated visitor', async () => { + const { controller, markdownService } = createController() + + await runWithAdminAccess(false, () => + controller.renderArticle({ id: 'post-1' } as any, undefined as any), + ) + + expect(markdownService.renderArticle).toHaveBeenCalledWith('post-1', { + asOwner: false, + }) + }) + + it('requests the full body for an admin-authenticated request', async () => { + const { controller, markdownService } = createController() + + await runWithAdminAccess(true, () => + controller.renderArticle({ id: 'post-1' } as any, undefined as any), + ) + + expect(markdownService.renderArticle).toHaveBeenCalledWith('post-1', { + asOwner: true, + }) + }) +}) From 692ae7cc65008a370aaa133772e32d40a9833f48 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 20:29:39 +0800 Subject: [PATCH 10/37] feat(reader): add membership summary and status filter to reader list Left-join memberships (1:1 on reader_id) into the reader list query so each row carries membership: { status, plan, provider, currentPeriodEnd } | null, and add a membershipStatus SQL filter (active/on_hold/cancelled/expired/none) with semantics matching EntitlementService's entitlement rule. --- .../src/modules/reader/reader.controller.ts | 6 +- .../src/modules/reader/reader.repository.ts | 79 ++++++++- .../core/src/modules/reader/reader.service.ts | 11 +- apps/core/src/modules/reader/reader.types.ts | 11 ++ .../reader/reader.repository.pg.e2e.spec.ts | 163 ++++++++++++++++++ 5 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts diff --git a/apps/core/src/modules/reader/reader.controller.ts b/apps/core/src/modules/reader/reader.controller.ts index 2ecb897fe68..de4c50cc220 100644 --- a/apps/core/src/modules/reader/reader.controller.ts +++ b/apps/core/src/modules/reader/reader.controller.ts @@ -15,6 +15,9 @@ const ReaderListQuerySchema = z.object({ size: z.coerce.number().int().positive().max(100).default(20), search: z.string().trim().optional(), role: z.enum(['all', 'owner', 'reader']).optional(), + membershipStatus: z + .enum(['active', 'on_hold', 'cancelled', 'expired', 'none']) + .optional(), }) class ReaderListQueryDto extends createZodDto(ReaderListQuerySchema) {} @@ -32,12 +35,13 @@ export class ReaderAuthController { @Get('/') async find(@Query() query: ReaderListQueryDto) { - const { page = 1, size = 20, search, role } = query + const { page = 1, size = 20, search, role, membershipStatus } = query const result = await this.readerService.findPaginated( page, size, search, role, + membershipStatus, ) return withMeta( result.data, diff --git a/apps/core/src/modules/reader/reader.repository.ts b/apps/core/src/modules/reader/reader.repository.ts index 2fc85a09bda..68b96beff5d 100644 --- a/apps/core/src/modules/reader/reader.repository.ts +++ b/apps/core/src/modules/reader/reader.repository.ts @@ -4,9 +4,12 @@ import { asc, desc, eq, + gt, ilike, inArray, isNotNull, + isNull, + lte, ne, or, type SQL, @@ -14,14 +17,14 @@ import { } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' -import { readers, sessions } from '~/database/schema' +import { memberships, readers, sessions } from '~/database/schema' import { BaseRepository, type PaginationResult, } from '~/processors/database/base.repository' import type { AppDatabase } from '~/processors/database/postgres.provider' -import type { ReaderRow } from './reader.types' +import type { ReaderMembershipStatusFilter, ReaderRow } from './reader.types' export type ReaderRoleFilter = 'all' | 'owner' | 'reader' @@ -30,6 +33,7 @@ export interface ReaderListParams { size?: number search?: string role?: ReaderRoleFilter + membershipStatus?: ReaderMembershipStatusFilter } export interface ReaderRoleCounts { @@ -71,6 +75,32 @@ const readerSelection = { updatedAt: readers.updatedAt, } as const +const membershipSelection = { + membershipId: memberships.id, + membershipStatus: memberships.status, + membershipPlan: memberships.plan, + membershipProvider: memberships.provider, + membershipCurrentPeriodEnd: memberships.currentPeriodEnd, +} as const + +type MembershipSelectionRow = { + membershipId: string | null + membershipStatus: string | null + membershipPlan: string | null + membershipProvider: string | null + membershipCurrentPeriodEnd: Date | null +} + +const mapMembership = (row: MembershipSelectionRow) => + row.membershipId + ? { + status: row.membershipStatus!, + plan: row.membershipPlan!, + provider: row.membershipProvider!, + currentPeriodEnd: row.membershipCurrentPeriodEnd!, + } + : null + @Injectable() export class ReaderRepository extends BaseRepository { constructor(@Inject(PG_DB_TOKEN) db: AppDatabase) { @@ -174,6 +204,38 @@ export class ReaderRepository extends BaseRepository { ) if (searchClause) conditions.push(searchClause) } + if (params.membershipStatus) { + const now = new Date() + switch (params.membershipStatus) { + case 'active': + case 'on_hold': { + conditions.push( + and( + eq(memberships.status, params.membershipStatus), + gt(memberships.currentPeriodEnd, now), + )!, + ) + break + } + case 'cancelled': { + conditions.push(eq(memberships.status, 'cancelled')) + break + } + case 'expired': { + conditions.push( + or( + lte(memberships.currentPeriodEnd, now), + eq(memberships.status, 'expired'), + )!, + ) + break + } + case 'none': { + conditions.push(isNull(memberships.id)) + break + } + } + } if (conditions.length === 0) return undefined return conditions.length === 1 ? conditions[0] : and(...conditions) } @@ -190,12 +252,21 @@ export class ReaderRepository extends BaseRepository { this.db .select({ ...readerSelection, + ...membershipSelection, lastLoginAt, }) .from(readers) .leftJoin(sessions, eq(sessions.userId, readers.id)) + .leftJoin(memberships, eq(memberships.readerId, readers.id)) .where(where) - .groupBy(readers.id) + .groupBy( + readers.id, + memberships.id, + memberships.status, + memberships.plan, + memberships.provider, + memberships.currentPeriodEnd, + ) .orderBy( sql`${lastLoginAt} desc nulls last`, desc(readers.createdAt), @@ -206,12 +277,14 @@ export class ReaderRepository extends BaseRepository { this.db .select({ count: sql`count(*)::int` }) .from(readers) + .leftJoin(memberships, eq(memberships.readerId, readers.id)) .where(where), ]) return { data: rows.map((row) => ({ ...mapRow(row), lastLoginAt: row.lastLoginAt, + membership: mapMembership(row), })), pagination: this.paginationOf(Number(count ?? 0), page, size), } diff --git a/apps/core/src/modules/reader/reader.service.ts b/apps/core/src/modules/reader/reader.service.ts index 69002c7c83d..e90e473e0d7 100644 --- a/apps/core/src/modules/reader/reader.service.ts +++ b/apps/core/src/modules/reader/reader.service.ts @@ -5,7 +5,12 @@ import { AppErrorCode, createAppException } from '~/common/errors' import { AuthService } from '../auth/auth.service' import type { ReaderRoleFilter } from './reader.repository' import { ReaderRepository } from './reader.repository' -import type { ReaderModel, ReaderRow } from './reader.types' +import type { + ReaderMembershipStatusFilter, + ReaderMembershipSummary, + ReaderModel, + ReaderRow, +} from './reader.types' type ReaderShape = ReaderModel & { id: string @@ -22,6 +27,7 @@ type ReaderShape = ReaderModel & { createdAt: Date updatedAt: Date | null lastLoginAt: Date | null + membership: ReaderMembershipSummary | null } @Injectable() @@ -38,6 +44,7 @@ export class ReaderService { bannedAt: row.bannedAt ?? null, banReason: row.banReason ?? null, lastLoginAt: row.lastLoginAt ?? null, + membership: row.membership ?? null, } as ReaderShape } @@ -51,12 +58,14 @@ export class ReaderService { size: number, search?: string, role?: ReaderRoleFilter, + membershipStatus?: ReaderMembershipStatusFilter, ) { const result = await this.readerRepository.list({ page, size, search, role, + membershipStatus, }) return { diff --git a/apps/core/src/modules/reader/reader.types.ts b/apps/core/src/modules/reader/reader.types.ts index 1ce9fdd57c3..17eb885ca64 100644 --- a/apps/core/src/modules/reader/reader.types.ts +++ b/apps/core/src/modules/reader/reader.types.ts @@ -11,6 +11,16 @@ export interface ReaderModel extends BaseModel { role?: string } +export type ReaderMembershipStatusFilter = + 'active' | 'on_hold' | 'cancelled' | 'expired' | 'none' + +export interface ReaderMembershipSummary { + status: string + plan: string + provider: string + currentPeriodEnd: Date +} + export interface ReaderRow { id: string email: string | null @@ -26,4 +36,5 @@ export interface ReaderRow { createdAt: Date updatedAt: Date | null lastLoginAt?: Date | null + membership?: ReaderMembershipSummary | null } diff --git a/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts b/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts new file mode 100644 index 00000000000..f32105f6a1a --- /dev/null +++ b/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts @@ -0,0 +1,163 @@ +import * as schema from '@mx-space/db-schema/schema' +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { createIsolatedPgDatabase } from 'test/helper/pg-testcontainer' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { ReaderRepository } from '~/modules/reader/reader.repository' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +describe('ReaderRepository membership summary + filter (real PG)', () => { + let pool: Pool + let database: Awaited> + let db: AppDatabase + let readerRepository: ReaderRepository + const snowflake = new SnowflakeService() + + const activeReaderId = snowflake.nextId() + const onHoldReaderId = snowflake.nextId() + const cancelledReaderId = snowflake.nextId() + const expiredStatusReaderId = snowflake.nextId() + const expiredPeriodReaderId = snowflake.nextId() + const noMembershipReaderId = snowflake.nextId() + + const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + const past = new Date(Date.now() - 24 * 60 * 60 * 1000) + + beforeAll(async () => { + database = await createIsolatedPgDatabase() + pool = new Pool({ connectionString: database.getConnectionUri(), max: 4 }) + db = drizzle(pool, { schema }) as unknown as AppDatabase + readerRepository = new ReaderRepository(db) + + await db.insert(schema.readers).values([ + { id: activeReaderId, name: 'Active Reader', role: 'reader' }, + { id: onHoldReaderId, name: 'On Hold Reader', role: 'reader' }, + { id: cancelledReaderId, name: 'Cancelled Reader', role: 'reader' }, + { + id: expiredStatusReaderId, + name: 'Expired Status Reader', + role: 'reader', + }, + { + id: expiredPeriodReaderId, + name: 'Expired Period Reader', + role: 'reader', + }, + { + id: noMembershipReaderId, + name: 'No Membership Reader', + role: 'reader', + }, + ]) + + await db.insert(schema.memberships).values([ + { + id: snowflake.nextId(), + readerId: activeReaderId, + provider: 'dodo', + plan: 'monthly', + status: 'active', + currentPeriodEnd: future, + }, + { + id: snowflake.nextId(), + readerId: onHoldReaderId, + provider: 'dodo', + plan: 'monthly', + status: 'on_hold', + currentPeriodEnd: future, + }, + { + id: snowflake.nextId(), + readerId: cancelledReaderId, + provider: 'manual', + plan: 'yearly', + status: 'cancelled', + currentPeriodEnd: future, + }, + { + id: snowflake.nextId(), + readerId: expiredStatusReaderId, + provider: 'dodo', + plan: 'monthly', + status: 'expired', + currentPeriodEnd: future, + }, + { + id: snowflake.nextId(), + readerId: expiredPeriodReaderId, + provider: 'dodo', + plan: 'monthly', + status: 'active', + currentPeriodEnd: past, + }, + ]) + }, 120_000) + + afterAll(async () => { + await pool?.end() + await database?.drop() + }) + + it('carries a membership summary for member and non-member rows', async () => { + const result = await readerRepository.list({ page: 1, size: 100 }) + const byId = new Map(result.data.map((row) => [row.id, row])) + + expect(byId.get(activeReaderId)?.membership).toEqual({ + status: 'active', + plan: 'monthly', + provider: 'dodo', + currentPeriodEnd: future, + }) + expect(byId.get(noMembershipReaderId)?.membership).toBeNull() + }) + + it('filters by membershipStatus=active', async () => { + const result = await readerRepository.list({ + page: 1, + size: 100, + membershipStatus: 'active', + }) + expect(result.data.map((row) => row.id)).toEqual([activeReaderId]) + }) + + it('filters by membershipStatus=on_hold', async () => { + const result = await readerRepository.list({ + page: 1, + size: 100, + membershipStatus: 'on_hold', + }) + expect(result.data.map((row) => row.id)).toEqual([onHoldReaderId]) + }) + + it('filters by membershipStatus=cancelled', async () => { + const result = await readerRepository.list({ + page: 1, + size: 100, + membershipStatus: 'cancelled', + }) + expect(result.data.map((row) => row.id)).toEqual([cancelledReaderId]) + }) + + it('filters by membershipStatus=expired (stored expired status or period passed)', async () => { + const result = await readerRepository.list({ + page: 1, + size: 100, + membershipStatus: 'expired', + }) + expect(new Set(result.data.map((row) => row.id))).toEqual( + new Set([expiredStatusReaderId, expiredPeriodReaderId]), + ) + }) + + it('filters by membershipStatus=none', async () => { + const result = await readerRepository.list({ + page: 1, + size: 100, + membershipStatus: 'none', + }) + expect(result.data.map((row) => row.id)).toEqual([noMembershipReaderId]) + }) +}) From e5d4d993d3d1d7883b2bef049b5ce192dbc2632d Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 20:36:27 +0800 Subject: [PATCH 11/37] feat(api-client): add membership controller and paywall types Add a typed membership client (checkout/status) mirroring the new /membership endpoints, plus isPremium on the post model and a paywall field on PostResponseMeta so consumers can type-check the locked-post response shape. PostController.getPost now returns a RequestProxyResultWithMeta typed with PostResponseMeta. --- .../__tests__/controllers/membership.test.ts | 43 +++++++++++++++++++ .../__tests__/controllers/post.test.ts | 15 +++++++ packages/api-client/controllers/index.ts | 4 ++ packages/api-client/controllers/membership.ts | 43 +++++++++++++++++++ packages/api-client/controllers/post.ts | 13 ++++-- packages/api-client/models/base.ts | 6 +++ packages/api-client/models/index.ts | 1 + packages/api-client/models/membership.ts | 24 +++++++++++ packages/api-client/models/post.ts | 2 + 9 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 packages/api-client/__tests__/controllers/membership.test.ts create mode 100644 packages/api-client/controllers/membership.ts create mode 100644 packages/api-client/models/membership.ts diff --git a/packages/api-client/__tests__/controllers/membership.test.ts b/packages/api-client/__tests__/controllers/membership.test.ts new file mode 100644 index 00000000000..167835e004c --- /dev/null +++ b/packages/api-client/__tests__/controllers/membership.test.ts @@ -0,0 +1,43 @@ +import { mockRequestInstance } from '~/__tests__/helpers/instance' +import { mockResponse } from '~/__tests__/helpers/response' +import { MembershipController } from '~/controllers' + +describe('test Membership client', () => { + const client = mockRequestInstance(MembershipController) + + test('POST /membership/checkout', async () => { + const mocked = mockResponse( + '/membership/checkout', + { checkout_url: 'https://pay.example.com/session/abc' }, + 'post', + { plan: 'monthly' }, + ) + + const data = await client.membership.checkout('monthly') + expect(data).toEqual({ checkoutUrl: mocked.checkout_url }) + }) + + test('GET /membership/status returns active membership', async () => { + const mocked = mockResponse('/membership/status', { + status: 'active', + plan: 'yearly', + provider: 'dodo', + current_period_end: '2027-07-18T00:00:00.000Z', + }) + + const data = await client.membership.status() + expect(data).toEqual({ + status: 'active', + plan: 'yearly', + provider: 'dodo', + currentPeriodEnd: mocked.current_period_end, + }) + }) + + test('GET /membership/status returns none for non-members', async () => { + mockResponse('/membership/status', { status: 'none' }) + + const data = await client.membership.status() + expect(data).toEqual({ status: 'none' }) + }) +}) diff --git a/packages/api-client/__tests__/controllers/post.test.ts b/packages/api-client/__tests__/controllers/post.test.ts index 1485a3cde71..7308fe219c8 100644 --- a/packages/api-client/__tests__/controllers/post.test.ts +++ b/packages/api-client/__tests__/controllers/post.test.ts @@ -70,6 +70,21 @@ describe('test post client', () => { expect(data.$raw).toBeDefined() }) + it('should expose paywall meta for a locked premium post', async () => { + mockResponse( + '/posts/613c91d0326cfffc61923ea2', + { title: '1', is_premium: true }, + 'get', + undefined, + { paywall: { locked: true, preview_blocks: 2 } }, + ) + + const data = await client.post.getPost('613c91d0326cfffc61923ea2') + + expect(data.isPremium).toBe(true) + expect(data.$meta?.paywall).toEqual({ locked: true, previewBlocks: 2 }) + }) + it('GET /posts/get-url/:slug', async () => { mockResponse('/posts/get-url/host-an-entire-Mix-Space-using-Docker', { path: '/website/host-an-entire-Mix-Space-using-Docker', diff --git a/packages/api-client/controllers/index.ts b/packages/api-client/controllers/index.ts index 2a58cba2f72..8c78fbf8b03 100644 --- a/packages/api-client/controllers/index.ts +++ b/packages/api-client/controllers/index.ts @@ -7,6 +7,7 @@ import { CommentController } from './comment' import { CompanionController } from './companion' import { EnrichmentController } from './enrichment' import { LinkController } from './link' +import { MembershipController } from './membership' import type { NoteMiddleListOptions, NoteTimelineItem, @@ -40,6 +41,7 @@ export const allControllers = [ CompanionController, EnrichmentController, LinkController, + MembershipController, NoteController, PageController, PostController, @@ -64,6 +66,7 @@ export const allControllerNames = [ 'companion', 'enrichment', 'link', + 'membership', 'note', 'page', 'post', @@ -92,6 +95,7 @@ export { CompanionController, EnrichmentController, LinkController, + MembershipController, NoteController, PageController, PostController, diff --git a/packages/api-client/controllers/membership.ts b/packages/api-client/controllers/membership.ts new file mode 100644 index 00000000000..3e8a63f1532 --- /dev/null +++ b/packages/api-client/controllers/membership.ts @@ -0,0 +1,43 @@ +import type { IRequestAdapter } from '~/interfaces/adapter' +import type { IController } from '~/interfaces/controller' +import type { IRequestHandler } from '~/interfaces/request' +import type { + MembershipCheckoutResult, + MembershipPlan, + MembershipStatusResult, +} from '~/models/membership' +import { autoBind } from '~/utils/auto-bind' + +import type { HTTPClient } from '../core' + +declare module '@mx-space/api-client' { + interface HTTPClient< + T extends IRequestAdapter = IRequestAdapter, + ResponseWrapper = unknown, + > { + membership: MembershipController + } +} + +export class MembershipController implements IController { + base = 'membership' + name = 'membership' + + constructor(protected client: HTTPClient) { + autoBind(this) + } + + public get proxy(): IRequestHandler { + return this.client.proxy(this.base) + } + + checkout(plan: MembershipPlan) { + return this.proxy.checkout.post({ + data: { plan }, + }) + } + + status() { + return this.proxy.status.get() + } +} diff --git a/packages/api-client/controllers/post.ts b/packages/api-client/controllers/post.ts index 68b43985974..96da02f87fe 100644 --- a/packages/api-client/controllers/post.ts +++ b/packages/api-client/controllers/post.ts @@ -1,7 +1,10 @@ import type { IRequestAdapter } from '~/interfaces/adapter' import type { IController } from '~/interfaces/controller' -import type { IRequestHandler, RequestProxyResult } from '~/interfaces/request' -import type { PaginateResult } from '~/models/base' +import type { + IRequestHandler, + RequestProxyResultWithMeta, +} from '~/interfaces/request' +import type { PaginateResult, PostResponseMeta } from '~/models/base' import type { PostModel } from '~/models/post' import { autoBind } from '~/utils/auto-bind' @@ -57,8 +60,10 @@ export class PostController implements IController { categoryName: string, slug: string, options?: { lang?: string; prefer?: 'lexical' }, - ): RequestProxyResult - getPost(id: string): RequestProxyResult + ): RequestProxyResultWithMeta + getPost( + id: string, + ): RequestProxyResultWithMeta getPost( idOrCategoryName: string, slug?: string, diff --git a/packages/api-client/models/base.ts b/packages/api-client/models/base.ts index 1bd43fa03c3..63df089f035 100644 --- a/packages/api-client/models/base.ts +++ b/packages/api-client/models/base.ts @@ -91,6 +91,11 @@ export interface SkillBundleView { assets: SkillAssetView[] } +export interface PaywallMeta { + locked: boolean + previewBlocks?: number +} + export interface BaseResponseMeta { pagination?: PaginationMeta view?: string @@ -105,6 +110,7 @@ export interface PostResponseMeta extends BaseResponseMeta { articles?: Record summary?: SummaryMeta skills?: SkillBundleView[] + paywall?: PaywallMeta } export interface NoteResponseMeta extends BaseResponseMeta { diff --git a/packages/api-client/models/index.ts b/packages/api-client/models/index.ts index 26e586af87c..10e5cf1a742 100644 --- a/packages/api-client/models/index.ts +++ b/packages/api-client/models/index.ts @@ -8,6 +8,7 @@ export * from './comment' export * from './companion' export * from './enrichment' export * from './link' +export * from './membership' export * from './note' export * from './page' export * from './post' diff --git a/packages/api-client/models/membership.ts b/packages/api-client/models/membership.ts new file mode 100644 index 00000000000..f396a8595d9 --- /dev/null +++ b/packages/api-client/models/membership.ts @@ -0,0 +1,24 @@ +export type MembershipProvider = + 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' + +export type MembershipPlan = 'monthly' | 'yearly' + +export type MembershipStatus = 'active' | 'on_hold' | 'cancelled' | 'expired' + +export interface MembershipCheckoutResult { + checkoutUrl: string +} + +export interface MembershipStatusResultNone { + status: 'none' +} + +export interface MembershipStatusResultActive { + status: MembershipStatus + plan: MembershipPlan + provider: MembershipProvider + currentPeriodEnd: string +} + +export type MembershipStatusResult = + MembershipStatusResultNone | MembershipStatusResultActive diff --git a/packages/api-client/models/post.ts b/packages/api-client/models/post.ts index 01e52db8bee..460ba09d48f 100644 --- a/packages/api-client/models/post.ts +++ b/packages/api-client/models/post.ts @@ -35,6 +35,7 @@ export interface PostModelMarkdown { pinOrder?: number | null related?: PostRelatedSummary[] enrichments?: EnrichmentMap + isPremium: boolean } export interface PostModelLexical { @@ -60,6 +61,7 @@ export interface PostModelLexical { pinOrder?: number | null related?: PostRelatedSummary[] enrichments?: EnrichmentMap + isPremium: boolean } export type PostModel = PostModelMarkdown | PostModelLexical From 2ab74b534ca955c6c7b7511a9a6e6a7cb211455b Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 20:51:52 +0800 Subject: [PATCH 12/37] feat(admin): add post editor paywall controls (isPremium, previewBlocks) Add a premium toggle (Lexical-only, per server contract) and a free-preview number input to the post write panel, persisting isPremium and meta.paywall.previewBlocks alongside the existing copyright/pin fields. --- apps/admin/src/api/posts.ts | 1 + .../src/data/resources/post.mutations.ts | 1 + .../components/WriteRouteViewsContent.tsx | 66 ++++++++++++++++++- apps/admin/src/i18n/resources/en-US.ts | 4 ++ apps/admin/src/i18n/resources/zh-CN.ts | 4 ++ apps/admin/src/models/draft.ts | 5 +- apps/admin/src/models/post.ts | 1 + 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/api/posts.ts b/apps/admin/src/api/posts.ts index 807d13f3b7f..496959dbdd6 100644 --- a/apps/admin/src/api/posts.ts +++ b/apps/admin/src/api/posts.ts @@ -27,6 +27,7 @@ export interface CreatePostData { copyright?: boolean draftId?: string images?: Image[] + isPremium?: boolean isPublished?: boolean meta?: Record pin?: null | string diff --git a/apps/admin/src/data/resources/post.mutations.ts b/apps/admin/src/data/resources/post.mutations.ts index 3b55f7befbb..615e624976b 100644 --- a/apps/admin/src/data/resources/post.mutations.ts +++ b/apps/admin/src/data/resources/post.mutations.ts @@ -74,6 +74,7 @@ function toOptimisticPostPatch(data: CreatePostData): Partial { categoryId: data.categoryId, contentFormat: data.contentFormat, copyright: data.copyright, + isPremium: data.isPremium, isPublished: data.isPublished, meta: data.meta, pinAt: data.pin, diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx index fb3793e4b31..281456922ac 100644 --- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx +++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx @@ -142,6 +142,7 @@ interface WriteFormState { coordinatesLat: string coordinatesLng: string copyright: boolean + isPremium: boolean isPublished: boolean images: NonNullable location: string @@ -152,6 +153,7 @@ interface WriteFormState { passwordProtected: boolean pin: boolean pinOrder: string + previewBlocks: string publicAt: string relatedId: string slug: string @@ -172,6 +174,7 @@ const emptyState: WriteFormState = { coordinatesLat: '', coordinatesLng: '', copyright: true, + isPremium: false, isPublished: true, images: [], location: '', @@ -182,6 +185,7 @@ const emptyState: WriteFormState = { passwordProtected: false, pin: false, pinOrder: '1', + previewBlocks: '3', publicAt: '', relatedId: '', slug: '', @@ -778,6 +782,9 @@ function WritePage(props: { kind: WriteKind }) { const updateContentFormat = (format: ContentFormat) => { setPreferredContentFormat(format) updateField('contentFormat', format) + if (format !== 'lexical' && state.isPremium) { + updateField('isPremium', false) + } } const getAgentMetaFields = () => getWriteAgentMetaFields(props.kind, state) @@ -1916,6 +1923,27 @@ function PostFields(props: { value={props.state.pinOrder} /> ) : null} + props.updateField('isPremium', checked)} + /> + {props.state.isPremium ? ( + props.updateField('previewBlocks', value)} + value={props.state.previewBlocks} + /> + ) : null} @@ -2846,6 +2874,21 @@ function setMetaValue( return next } +function getPaywallPreviewBlocks(meta: Record) { + const paywall = isRecord(meta.paywall) ? meta.paywall : undefined + return typeof paywall?.previewBlocks === 'number' + ? paywall.previewBlocks + : undefined +} + +function withPaywallPreviewBlocks( + meta: Record, + previewBlocks: number, +) { + const paywall = isRecord(meta.paywall) ? meta.paywall : {} + return { ...meta, paywall: { ...paywall, previewBlocks } } +} + function formatMetaJson(meta: Record) { return Object.keys(meta).length > 0 ? JSON.stringify(meta, null, 2) : '' } @@ -3045,10 +3088,14 @@ function fromModel(kind: WriteKind, model: WriteModel) { contentFormat: post.contentFormat ?? 'markdown', copyright: post.copyright, images: post.images ?? [], + isPremium: Boolean(post.isPremium), isPublished: post.isPublished ?? true, meta: isRecord(post.meta) ? post.meta : {}, pin: Boolean(post.pinAt), pinOrder: String(post.pinOrder ?? 1), + previewBlocks: String( + getPaywallPreviewBlocks(isRecord(post.meta) ? post.meta : {}) ?? 3, + ), relatedId: post.related?.map((item) => item.id).join(', ') ?? '', slug: post.slug, summary: post.summary ?? '', @@ -3132,6 +3179,10 @@ function fromDraft( typeof specific.copyright === 'boolean' ? specific.copyright : previous.copyright, + isPremium: + typeof specific.isPremium === 'boolean' + ? specific.isPremium + : previous.isPremium, isPublished: typeof specific.isPublished === 'boolean' ? specific.isPublished @@ -3141,6 +3192,10 @@ function fromDraft( typeof specific.pinOrder === 'number' ? String(specific.pinOrder) : previous.pinOrder, + previewBlocks: String( + getPaywallPreviewBlocks(base.meta) ?? + (Number(previous.previewBlocks) || 3), + ), relatedId: Array.isArray(specific.relatedId) ? specific.relatedId.map((id) => String(id)).join(', ') : previous.relatedId, @@ -3263,8 +3318,12 @@ function buildPostWriteData( projected.contentFormat === 'lexical' ? undefined : buildWriteImages(projected), + isPremium: projected.isPremium, isPublished: projected.isPublished, - meta: projected.meta, + meta: withPaywallPreviewBlocks( + projected.meta, + Math.max(1, Number(projected.previewBlocks) || 3), + ), pin: projected.pin ? new Date().toISOString() : null, pinOrder: projected.pin ? Number(projected.pinOrder) || 1 : null, relatedId: splitCommaList(projected.relatedId), @@ -3376,9 +3435,14 @@ function toDraftData( if (kind === 'post') { return { ...base, + meta: withPaywallPreviewBlocks( + state.meta, + Math.max(1, Number(state.previewBlocks) || 3), + ), typeSpecificData: { categoryId: state.categoryId, copyright: state.copyright, + isPremium: state.isPremium, isPublished: state.isPublished, pin: state.pin ? new Date().toISOString() : null, pinOrder: state.pin ? Number(state.pinOrder) || 1 : undefined, diff --git a/apps/admin/src/i18n/resources/en-US.ts b/apps/admin/src/i18n/resources/en-US.ts index 66e980d7d88..079b81b722e 100644 --- a/apps/admin/src/i18n/resources/en-US.ts +++ b/apps/admin/src/i18n/resources/en-US.ts @@ -2906,6 +2906,10 @@ export const enUS = { 'write.postFields.copyright': 'Copyright', 'write.postFields.pin': 'Pin', 'write.postFields.pinOrder': 'Pin order', + 'write.postFields.premium': 'Premium (paywalled)', + 'write.postFields.premiumPreviewBlocks': 'Free preview blocks', + 'write.postFields.premiumRequiresLexical': + 'Switch to the Lexical editor to enable premium content.', 'write.postFields.related.addAria': 'Add related post', 'write.postFields.related.empty': 'No posts to relate.', 'write.postFields.related.idLabel': 'Related post IDs', diff --git a/apps/admin/src/i18n/resources/zh-CN.ts b/apps/admin/src/i18n/resources/zh-CN.ts index 081d6459a56..2ab195275c6 100644 --- a/apps/admin/src/i18n/resources/zh-CN.ts +++ b/apps/admin/src/i18n/resources/zh-CN.ts @@ -2777,6 +2777,10 @@ export const zhCN = { 'write.postFields.copyright': '版权声明', 'write.postFields.pin': '置顶', 'write.postFields.pinOrder': '置顶顺序', + 'write.postFields.premium': '付费内容(会员解锁)', + 'write.postFields.premiumPreviewBlocks': '免费预览段落数', + 'write.postFields.premiumRequiresLexical': + '切换到 Lexical 编辑器后可启用付费内容。', 'write.postFields.related.addAria': '添加关联文章', 'write.postFields.related.empty': '暂无可关联文章。', 'write.postFields.related.idLabel': '相关文章 ID', diff --git a/apps/admin/src/models/draft.ts b/apps/admin/src/models/draft.ts index a653f47b706..4480ee46ec3 100644 --- a/apps/admin/src/models/draft.ts +++ b/apps/admin/src/models/draft.ts @@ -61,6 +61,7 @@ export interface PostSpecificData { pinOrder?: number relatedId?: string[] isPublished?: boolean + isPremium?: boolean } export interface NoteSpecificData { @@ -86,6 +87,4 @@ export interface PageSpecificData { } export type TypeSpecificData = - | PostSpecificData - | NoteSpecificData - | PageSpecificData + PostSpecificData | NoteSpecificData | PageSpecificData diff --git a/apps/admin/src/models/post.ts b/apps/admin/src/models/post.ts index bfde914dbc9..78059ff6d8c 100644 --- a/apps/admin/src/models/post.ts +++ b/apps/admin/src/models/post.ts @@ -37,6 +37,7 @@ export interface PostModel { >[] meta?: any isPublished?: boolean + isPremium?: boolean } export interface Category { From 3c021e4a44b8cbf7971939da02c80872e366f101 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 20:58:56 +0800 Subject: [PATCH 13/37] fix(admin): strip stale paywall.previewBlocks when post is not premium WriteRouteViewsContent's save and draft paths wrote meta.paywall.previewBlocks unconditionally, leaving a stale paywall key on posts after isPremium was toggled off (or on posts that were never premium). resolvePaywallMeta() now merges previewBlocks only when isPremium is true and otherwise strips the previewBlocks key (and the whole paywall object if it becomes empty) while preserving all other meta keys. Investigated the models/draft.ts formatting finding but left it unchanged: the current two-line union form is what this repo's actual Prettier config produces for TypeSpecificData, confirmed by running prettier over the older multi-line piped form and seeing it collapse back down. --- .../components/WriteRouteViewsContent.tsx | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx index 281456922ac..c1e17efee64 100644 --- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx +++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx @@ -2889,6 +2889,30 @@ function withPaywallPreviewBlocks( return { ...meta, paywall: { ...paywall, previewBlocks } } } +function withoutPaywallPreviewBlocks(meta: Record) { + if (!isRecord(meta.paywall)) { + return meta + } + + const { previewBlocks, ...restPaywall } = meta.paywall + if (Object.keys(restPaywall).length === 0) { + const { paywall, ...restMeta } = meta + return restMeta + } + + return { ...meta, paywall: restPaywall } +} + +function resolvePaywallMeta( + meta: Record, + isPremium: boolean, + previewBlocks: number, +) { + return isPremium + ? withPaywallPreviewBlocks(meta, previewBlocks) + : withoutPaywallPreviewBlocks(meta) +} + function formatMetaJson(meta: Record) { return Object.keys(meta).length > 0 ? JSON.stringify(meta, null, 2) : '' } @@ -3320,8 +3344,9 @@ function buildPostWriteData( : buildWriteImages(projected), isPremium: projected.isPremium, isPublished: projected.isPublished, - meta: withPaywallPreviewBlocks( + meta: resolvePaywallMeta( projected.meta, + projected.isPremium, Math.max(1, Number(projected.previewBlocks) || 3), ), pin: projected.pin ? new Date().toISOString() : null, @@ -3435,8 +3460,9 @@ function toDraftData( if (kind === 'post') { return { ...base, - meta: withPaywallPreviewBlocks( + meta: resolvePaywallMeta( state.meta, + state.isPremium, Math.max(1, Number(state.previewBlocks) || 3), ), typeSpecificData: { From 781a3bdcc9a5cea8db73e69b25e4f5be5375ac72 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 21:13:03 +0800 Subject: [PATCH 14/37] feat(admin): membership UI on readers page Adds the reader-list membership badge (effective status derived from raw status + currentPeriodEnd, with lapsed active/on_hold shown as "expired"), a membershipStatus filter in the readers toolbar, and a detail-page membership block with owner grant/extend and revoke actions. - api/readers.ts: membership types, membershipStatus list param, and PUT/DELETE /membership/members/:readerId calls - ReaderDetailRoute merges membership from the list-cache snapshot since GET /readers/:id omits it; mutations patch the detail cache directly after invalidation so the merge survives a refetch - MembershipGrantModal mirrors BanReaderModal's present/useModal shape --- apps/admin/src/api/readers.ts | 36 +++++- .../readers/components/ReaderDetailRoute.tsx | 7 +- .../readers/components/ReaderListRow.tsx | 28 +++++ .../components/ReaderMembershipBlock.tsx | 112 ++++++++++++++++++ .../readers/components/ReadersDetailPane.tsx | 6 +- .../readers/components/ReadersListPanel.tsx | 13 +- .../components/ReadersRouteViewContent.tsx | 2 + .../readers/components/ReadersToolbar.tsx | 18 +++ .../modals/MembershipGrantModal.tsx | 89 ++++++++++++++ apps/admin/src/features/readers/constants.ts | 17 ++- .../readers/hooks/useReaderMutations.ts | 53 ++++++++- .../features/readers/hooks/useReadersList.ts | 46 ++++++- .../src/features/readers/types/readers.ts | 5 + .../readers/utils/membership-status.ts | 59 +++++++++ apps/admin/src/i18n/resources/en-US.ts | 27 +++++ apps/admin/src/i18n/resources/zh-CN.ts | 26 ++++ apps/admin/src/query/keys.ts | 1 + 17 files changed, 531 insertions(+), 14 deletions(-) create mode 100644 apps/admin/src/features/readers/components/ReaderMembershipBlock.tsx create mode 100644 apps/admin/src/features/readers/components/modals/MembershipGrantModal.tsx create mode 100644 apps/admin/src/features/readers/utils/membership-status.ts diff --git a/apps/admin/src/api/readers.ts b/apps/admin/src/api/readers.ts index 69742e516b8..325b2e171b7 100644 --- a/apps/admin/src/api/readers.ts +++ b/apps/admin/src/api/readers.ts @@ -1,10 +1,23 @@ import type { PaginateResult } from '~/models/base' -import { getJson, patchJson } from './http' +import { deleteJson, getJson, patchJson, putJson } from './http' export type ReaderRole = 'reader' | 'owner' export type ReaderRoleFilter = 'all' | 'owner' | 'reader' +export type MembershipProvider = + 'creem' | 'dodo' | 'lemonsqueezy' | 'manual' | 'stripe' +export type MembershipPlan = 'monthly' | 'yearly' +export type MembershipStatus = 'active' | 'cancelled' | 'expired' | 'on_hold' +export type ReaderMembershipStatusFilter = 'none' | MembershipStatus + +export interface ReaderMembership { + status: MembershipStatus + plan: MembershipPlan + provider: MembershipProvider + currentPeriodEnd: string +} + export interface ReaderModel { id: string email: string | null @@ -20,6 +33,11 @@ export interface ReaderModel { createdAt: string updatedAt: string | null lastLoginAt: string | null + /** + * Only present on list rows (`GET /readers`). The single-reader detail + * endpoint (`GET /readers/:id`) does not include membership. + */ + membership?: ReaderMembership | null } export interface ReaderStats { @@ -34,10 +52,12 @@ export interface ReaderListParams { size: number search?: string role?: ReaderRoleFilter + membershipStatus?: ReaderMembershipStatusFilter } export function getReaders(params: ReaderListParams) { return getJson>('/readers', { + membershipStatus: params.membershipStatus, page: params.page, role: params.role, search: params.search, @@ -73,3 +93,17 @@ export function unbanReader(id: string) { {}, ) } + +export function grantMembership( + readerId: string, + data: { plan: MembershipPlan; expiresAt: string }, +) { + return putJson( + `/membership/members/${readerId}`, + data, + ) +} + +export function revokeMembership(readerId: string) { + return deleteJson(`/membership/members/${readerId}`) +} diff --git a/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx b/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx index f10860230a0..5c3c7173115 100644 --- a/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx +++ b/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx @@ -57,13 +57,18 @@ export function ReaderDetailRoute() { return } + const readerWithMembership: ReaderModel = + reader.membership !== undefined + ? reader + : { ...reader, membership: initialReader?.membership ?? null } + return ( ) } diff --git a/apps/admin/src/features/readers/components/ReaderListRow.tsx b/apps/admin/src/features/readers/components/ReaderListRow.tsx index a8455ec5e68..a94a28c2d85 100644 --- a/apps/admin/src/features/readers/components/ReaderListRow.tsx +++ b/apps/admin/src/features/readers/components/ReaderListRow.tsx @@ -8,6 +8,14 @@ import { ListRow } from '~/ui/list-actions' import { cn } from '~/utils/cn' import { relativeTimeFromNow } from '~/utils/time' +import { + effectiveMembershipStatus, + MEMBERSHIP_PLAN_LABEL_KEY, + MEMBERSHIP_STATUS_LABEL_KEY, + MEMBERSHIP_STATUS_TONE, + providerLabel, +} from '../utils/membership-status' + interface ReaderListRowProps { reader: ReaderModel selected: boolean @@ -25,6 +33,7 @@ export function ReaderListRow(props: ReaderListRowProps) { const banned = Boolean(reader.bannedAt) const displayName = reader.name || reader.username || reader.handle || reader.id + const membershipStatus = effectiveMembershipStatus(reader.membership) return ( ) : null} + {membershipStatus !== 'none' ? ( + + {t(MEMBERSHIP_STATUS_LABEL_KEY[membershipStatus])} + + ) : null}
+ {reader.membership ? ( + <> + + {t(MEMBERSHIP_PLAN_LABEL_KEY[reader.membership.plan])} ·{' '} + {providerLabel(reader.membership.provider)} + + + + ) : null} {reader.handle ? ( @{reader.handle} ) : null} diff --git a/apps/admin/src/features/readers/components/ReaderMembershipBlock.tsx b/apps/admin/src/features/readers/components/ReaderMembershipBlock.tsx new file mode 100644 index 00000000000..bdf8335a038 --- /dev/null +++ b/apps/admin/src/features/readers/components/ReaderMembershipBlock.tsx @@ -0,0 +1,112 @@ +import type { ReaderModel } from '~/api/readers' +import { useI18n } from '~/i18n' +import { StatusPill } from '~/ui/data/StatusPill' +import { confirmDialog } from '~/ui/feedback/confirm' +import { Button } from '~/ui/primitives/button' +import { parseDate } from '~/utils/time' + +import type { useReaderMutations } from '../hooks/useReaderMutations' +import { + effectiveMembershipStatus, + hasLiveProviderManagedMembership, + MEMBERSHIP_PLAN_LABEL_KEY, + MEMBERSHIP_STATUS_LABEL_KEY, + MEMBERSHIP_STATUS_TONE, + providerLabel, +} from '../utils/membership-status' +import { presentMembershipGrantModal } from './modals/MembershipGrantModal' + +export function ReaderMembershipBlock(props: { + reader: ReaderModel + mutations: ReturnType +}) { + const { t } = useI18n() + const { reader, mutations } = props + const membership = reader.membership ?? null + const status = effectiveMembershipStatus(membership) + const liveProviderManaged = hasLiveProviderManagedMembership(membership) + const isManual = membership?.provider === 'manual' + const name = reader.name ?? reader.handle ?? reader.id + + const handleGrant = async () => { + const result = await presentMembershipGrantModal(reader) + if (!result) return + mutations.grantMembership.mutate({ readerId: reader.id, ...result }) + } + + const handleRevoke = async () => { + const ok = await confirmDialog({ + description: t('readers.membership.revoke.desc', { name }), + destructive: true, + title: t('readers.membership.revoke.title'), + }) + if (ok) mutations.revokeMembership.mutate(reader.id) + } + + return ( +
+

+ {t('readers.membership.section.title')} +

+ +
+
+ + {t(MEMBERSHIP_STATUS_LABEL_KEY[status])} + + {membership ? ( + + {t(MEMBERSHIP_PLAN_LABEL_KEY[membership.plan])} ·{' '} + {providerLabel(membership.provider)} + + ) : null} +
+ + {membership ? ( +
+ {t('readers.membership.periodEnd', { + date: parseDate( + membership.currentPeriodEnd, + 'yyyy 年 M 月 d 日 HH:mm:ss', + ), + })} +
+ ) : null} + +
+ {!liveProviderManaged ? ( + + ) : null} + {isManual ? ( + + ) : null} + {liveProviderManaged ? ( +

+ {t('readers.membership.providerManagedHint')} +

+ ) : null} +
+
+
+ ) +} diff --git a/apps/admin/src/features/readers/components/ReadersDetailPane.tsx b/apps/admin/src/features/readers/components/ReadersDetailPane.tsx index ac1812166dd..096a2024ab6 100644 --- a/apps/admin/src/features/readers/components/ReadersDetailPane.tsx +++ b/apps/admin/src/features/readers/components/ReadersDetailPane.tsx @@ -1,13 +1,14 @@ import { Loader2 } from 'lucide-react' -import type { ReaderModel } from '~/api/readers' -import type { useReaderMutations } from '../hooks/useReaderMutations' +import type { ReaderModel } from '~/api/readers' import { Scroll } from '~/ui/primitives/scroll' +import type { useReaderMutations } from '../hooks/useReaderMutations' import { ReaderActionsFooter } from './ReaderActionsFooter' import { ReaderActivityBlock } from './ReaderActivityBlock' import { ReaderDetailHeader } from './ReaderDetailHeader' import { ReaderIdentityBlock } from './ReaderIdentityBlock' +import { ReaderMembershipBlock } from './ReaderMembershipBlock' import { ReaderStatusBanner } from './ReaderStatusBanner' export function ReadersDetailPane(props: { @@ -39,6 +40,7 @@ export function ReadersDetailPane(props: { unbanPending={mutations.unbanReader.isPending} /> +
diff --git a/apps/admin/src/features/readers/components/ReadersListPanel.tsx b/apps/admin/src/features/readers/components/ReadersListPanel.tsx index 65bfcfe8df7..b19be4a00b9 100644 --- a/apps/admin/src/features/readers/components/ReadersListPanel.tsx +++ b/apps/admin/src/features/readers/components/ReadersListPanel.tsx @@ -1,4 +1,9 @@ -import type { ReaderModel, ReaderRoleFilter, ReaderStats } from '~/api/readers' +import type { + ReaderMembershipStatusFilter, + ReaderModel, + ReaderRoleFilter, + ReaderStats, +} from '~/api/readers' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' import { useI18n } from '~/i18n' import type { Pager } from '~/models/base' @@ -29,6 +34,10 @@ interface ReadersListPanelProps { onSearchChange: (value: string) => void role: ReaderRoleFilter onRoleChange: (role: ReaderRoleFilter) => void + membershipStatus: 'all' | ReaderMembershipStatusFilter + onMembershipStatusChange: ( + value: 'all' | ReaderMembershipStatusFilter, + ) => void page: number onPageChange: (page: number) => void onRefresh: () => void @@ -94,6 +103,8 @@ export function ReadersListPanel(props: ReadersListPanelProps) { void + membershipStatus: 'all' | ReaderMembershipStatusFilter + onMembershipStatusChange: ( + value: 'all' | ReaderMembershipStatusFilter, + ) => void onRefresh: () => void isFetching: boolean } @@ -29,6 +37,16 @@ export function ReadersToolbar(props: ReadersToolbarProps) { value={props.search} /> + ({ + label: t(filter.labelKey), + value: filter.value, + }))} + triggerClassName="w-36 shrink-0" + value={props.membershipStatus} + /> + + + + ) +} + +export async function presentMembershipGrantModal( + reader: ReaderModel, +): Promise { + const handle = present( + MembershipGrantModal, + { reader }, + { modalProps: { popupStyle: { width: 'min(92vw, 26rem)' } } }, + ) + return await handle +} diff --git a/apps/admin/src/features/readers/constants.ts b/apps/admin/src/features/readers/constants.ts index 37f3010fe74..0d4cfd3ded7 100644 --- a/apps/admin/src/features/readers/constants.ts +++ b/apps/admin/src/features/readers/constants.ts @@ -1,4 +1,7 @@ -import type { ReaderRoleFilter } from '~/api/readers' +import type { + ReaderMembershipStatusFilter, + ReaderRoleFilter, +} from '~/api/readers' import type { TranslationKey } from '~/i18n/types' import { adminQueryKeys } from '~/query/keys' @@ -15,3 +18,15 @@ export const ROLE_TABS: { { labelKey: 'readers.tab.owners', value: 'owner' }, { labelKey: 'readers.tab.readers', value: 'reader' }, ] + +export const MEMBERSHIP_STATUS_FILTERS: { + value: 'all' | ReaderMembershipStatusFilter + labelKey: TranslationKey +}[] = [ + { labelKey: 'readers.membership.filter.all', value: 'all' }, + { labelKey: 'readers.membership.status.active', value: 'active' }, + { labelKey: 'readers.membership.status.onHold', value: 'on_hold' }, + { labelKey: 'readers.membership.status.cancelled', value: 'cancelled' }, + { labelKey: 'readers.membership.status.expired', value: 'expired' }, + { labelKey: 'readers.membership.status.none', value: 'none' }, +] diff --git a/apps/admin/src/features/readers/hooks/useReaderMutations.ts b/apps/admin/src/features/readers/hooks/useReaderMutations.ts index 47444333d50..df7183c62ea 100644 --- a/apps/admin/src/features/readers/hooks/useReaderMutations.ts +++ b/apps/admin/src/features/readers/hooks/useReaderMutations.ts @@ -1,15 +1,21 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import type { ReaderModel } from '~/api/readers' -import type { TranslationKey } from '~/i18n/types' +import type { + MembershipPlan, + ReaderMembership, + ReaderModel, +} from '~/api/readers' import { banReader, + grantMembership, + revokeMembership, revokeOwner, transferOwner, unbanReader, } from '~/api/readers' import { useI18n } from '~/i18n' +import type { TranslationKey } from '~/i18n/types' import { adminQueryKeys } from '~/query/keys' import { readersQueryKey } from '../constants' @@ -24,6 +30,12 @@ interface BanVariables { reason?: string } +interface GrantMembershipVariables { + readerId: string + plan: MembershipPlan + expiresAt: string +} + interface DetailMutationContext { previous?: ReaderModel } @@ -123,8 +135,45 @@ export function useReaderMutations() { }, }) + const grantMembershipMutation = useMutation< + ReaderMembership, + unknown, + GrantMembershipVariables + >({ + mutationFn: ({ readerId, plan, expiresAt }) => + grantMembership(readerId, { expiresAt, plan }), + onError: (error) => + failureToast(error, 'readers.toast.membershipGrantFailed'), + onSuccess: async (membership, { readerId }) => { + successToast('readers.toast.membershipGranted') + await invalidate() + queryClient.setQueryData(detailKey(readerId), (current) => + current ? { ...current, membership } : current, + ) + }, + }) + + const revokeMembershipMutation = useMutation< + ReaderMembership, + unknown, + string + >({ + mutationFn: (readerId: string) => revokeMembership(readerId), + onError: (error) => + failureToast(error, 'readers.toast.membershipRevokeFailed'), + onSuccess: async (membership, readerId) => { + successToast('readers.toast.membershipRevoked') + await invalidate() + queryClient.setQueryData(detailKey(readerId), (current) => + current ? { ...current, membership } : current, + ) + }, + }) + return { banReader: banMutation, + grantMembership: grantMembershipMutation, + revokeMembership: revokeMembershipMutation, revokeOwner: revokeOwnerMutation, transferOwner: transferOwnerMutation, unbanReader: unbanMutation, diff --git a/apps/admin/src/features/readers/hooks/useReadersList.ts b/apps/admin/src/features/readers/hooks/useReadersList.ts index e21c1d6be6f..ae6bcd86b41 100644 --- a/apps/admin/src/features/readers/hooks/useReadersList.ts +++ b/apps/admin/src/features/readers/hooks/useReadersList.ts @@ -1,22 +1,39 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' -import type { ReaderRoleFilter } from '~/api/readers' +import type { + ReaderMembershipStatusFilter, + ReaderRoleFilter, +} from '~/api/readers' import { getReaders } from '~/api/readers' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' import { adminQueryKeys } from '~/query/keys' -import { - readersPageSize, - readersQueryKey, - searchDebounceMs, -} from '../constants' +import { readersPageSize, searchDebounceMs } from '../constants' + +const MEMBERSHIP_STATUS_VALUES: ReaderMembershipStatusFilter[] = [ + 'active', + 'on_hold', + 'cancelled', + 'expired', + 'none', +] function parseRole(value: string | null): ReaderRoleFilter { if (value === 'owner' || value === 'reader') return value return 'all' } +function parseMembershipStatus( + value: string | null, +): 'all' | ReaderMembershipStatusFilter { + return MEMBERSHIP_STATUS_VALUES.includes( + value as ReaderMembershipStatusFilter, + ) + ? (value as ReaderMembershipStatusFilter) + : 'all' +} + function parsePage(value: string | null): number { const parsed = Number(value) return Number.isInteger(parsed) && parsed > 1 ? parsed : 1 @@ -26,17 +43,23 @@ export function useReadersList() { const urlStateOptions = useMemo( () => ({ read: (searchParams: URLSearchParams) => ({ + membershipStatus: parseMembershipStatus( + searchParams.get('membershipStatus'), + ), page: parsePage(searchParams.get('page')), role: parseRole(searchParams.get('role')), search: searchParams.get('q') ?? '', }), write: (state: { + membershipStatus: 'all' | ReaderMembershipStatusFilter page: number role: ReaderRoleFilter search: string }) => { const nextParams = new URLSearchParams() if (state.role !== 'all') nextParams.set('role', state.role) + if (state.membershipStatus !== 'all') + nextParams.set('membershipStatus', state.membershipStatus) if (state.search) nextParams.set('q', state.search) if (state.page > 1) nextParams.set('page', String(state.page)) return nextParams @@ -60,12 +83,15 @@ export function useReadersList() { placeholderData: keepPreviousData, queryFn: () => getReaders({ + membershipStatus: + state.membershipStatus === 'all' ? undefined : state.membershipStatus, page: state.page, role: state.role === 'all' ? undefined : state.role, search: debouncedSearch.trim() || undefined, size: readersPageSize, }), queryKey: adminQueryKeys.readers.list({ + membershipStatus: state.membershipStatus, page: state.page, role: state.role, search: debouncedSearch.trim(), @@ -81,18 +107,26 @@ export function useReadersList() { setState((current) => ({ ...current, page: 1, role: value })) } + const changeMembershipStatus = ( + value: 'all' | ReaderMembershipStatusFilter, + ) => { + setState((current) => ({ ...current, membershipStatus: value, page: 1 })) + } + const listQueryString = urlStateOptions.write(state).toString() return { isFetching: readersQuery.isFetching, isLoading: readersQuery.isLoading, listQueryString, + membershipStatus: state.membershipStatus, page: state.page, pagination: readersQuery.data?.pagination, readers: readersQuery.data?.data ?? [], refetch: () => void readersQuery.refetch(), role: state.role, search: state.search, + setMembershipStatus: changeMembershipStatus, setPage: (page: number) => setState({ page }), setRole: changeRole, setSearch: changeSearch, diff --git a/apps/admin/src/features/readers/types/readers.ts b/apps/admin/src/features/readers/types/readers.ts index 6a453c162fb..96499bbdff3 100644 --- a/apps/admin/src/features/readers/types/readers.ts +++ b/apps/admin/src/features/readers/types/readers.ts @@ -1,5 +1,10 @@ export type { + MembershipPlan, + MembershipProvider, + MembershipStatus, ReaderListParams, + ReaderMembership, + ReaderMembershipStatusFilter, ReaderModel, ReaderRole, ReaderRoleFilter, diff --git a/apps/admin/src/features/readers/utils/membership-status.ts b/apps/admin/src/features/readers/utils/membership-status.ts new file mode 100644 index 00000000000..434d11ce5a0 --- /dev/null +++ b/apps/admin/src/features/readers/utils/membership-status.ts @@ -0,0 +1,59 @@ +import type { + ReaderMembership, + ReaderMembershipStatusFilter, +} from '~/api/readers' +import type { TranslationKey } from '~/i18n/types' + +export const MEMBERSHIP_STATUS_TONE = { + active: 'live', + on_hold: 'pending', + cancelled: 'archived', + expired: 'error', + none: 'archived', +} as const + +export const MEMBERSHIP_STATUS_LABEL_KEY: Record< + ReaderMembershipStatusFilter, + TranslationKey +> = { + active: 'readers.membership.status.active', + on_hold: 'readers.membership.status.onHold', + cancelled: 'readers.membership.status.cancelled', + expired: 'readers.membership.status.expired', + none: 'readers.membership.status.none', +} + +export const MEMBERSHIP_PLAN_LABEL_KEY: Record< + ReaderMembership['plan'], + TranslationKey +> = { + monthly: 'readers.membership.plan.monthly', + yearly: 'readers.membership.plan.yearly', +} + +export function effectiveMembershipStatus( + membership: ReaderMembership | null | undefined, +): ReaderMembershipStatusFilter { + if (!membership) return 'none' + if (membership.status === 'cancelled') return 'cancelled' + + const periodEnd = new Date(membership.currentPeriodEnd).getTime() + const lapsed = Number.isFinite(periodEnd) && periodEnd <= Date.now() + + return lapsed ? 'expired' : membership.status +} + +export function providerLabel(provider: ReaderMembership['provider']): string { + return provider.charAt(0).toUpperCase() + provider.slice(1) +} + +export function hasLiveProviderManagedMembership( + membership: ReaderMembership | null | undefined, +): boolean { + if (!membership || membership.provider === 'manual') return false + if (membership.status === 'active') return true + if (membership.status === 'on_hold') { + return new Date(membership.currentPeriodEnd).getTime() > Date.now() + } + return false +} diff --git a/apps/admin/src/i18n/resources/en-US.ts b/apps/admin/src/i18n/resources/en-US.ts index 079b81b722e..0ac83f6b9bf 100644 --- a/apps/admin/src/i18n/resources/en-US.ts +++ b/apps/admin/src/i18n/resources/en-US.ts @@ -1544,6 +1544,29 @@ export const enUS = { 'readers.detail.verified': 'Verified', 'readers.empty.search': 'No readers match your search.', 'readers.empty.title': 'No readers', + 'readers.membership.action.extend': 'Extend', + 'readers.membership.action.grant': 'Grant', + 'readers.membership.action.revoke': 'Revoke', + 'readers.membership.filter.all': 'All memberships', + 'readers.membership.filter.label': 'Membership status', + 'readers.membership.grant.expiresAtLabel': 'Expires at', + 'readers.membership.grant.planLabel': 'Plan', + 'readers.membership.grant.submit': 'Save', + 'readers.membership.grant.title': 'Grant membership', + 'readers.membership.periodEnd': 'Renews/expires on {date}', + 'readers.membership.plan.monthly': 'Monthly', + 'readers.membership.plan.yearly': 'Yearly', + 'readers.membership.providerManagedHint': + 'Managed by the payment provider. Manage plan or cancellation in the provider dashboard.', + 'readers.membership.revoke.desc': + 'Revoke the manual membership grant for {name}?', + 'readers.membership.revoke.title': 'Revoke membership', + 'readers.membership.section.title': 'Membership', + 'readers.membership.status.active': 'Active', + 'readers.membership.status.cancelled': 'Cancelled', + 'readers.membership.status.expired': 'Expired', + 'readers.membership.status.none': 'No membership', + 'readers.membership.status.onHold': 'On hold', 'readers.refresh': 'Refresh', 'readers.revokeOwner.desc': 'Revoke owner privileges from {name}?', 'readers.revokeOwner.title': 'Revoke owner', @@ -1559,6 +1582,10 @@ export const enUS = { 'readers.title': 'Readers', 'readers.toast.banFailed': 'Failed to ban reader', 'readers.toast.banned': 'Reader banned', + 'readers.toast.membershipGrantFailed': 'Failed to grant membership', + 'readers.toast.membershipGranted': 'Membership granted', + 'readers.toast.membershipRevokeFailed': 'Failed to revoke membership', + 'readers.toast.membershipRevoked': 'Membership revoked', 'readers.toast.revokeFailed': 'Failed to revoke owner', 'readers.toast.revoked': 'Owner revoked', 'readers.toast.transferFailed': 'Failed to transfer owner', diff --git a/apps/admin/src/i18n/resources/zh-CN.ts b/apps/admin/src/i18n/resources/zh-CN.ts index 2ab195275c6..c616df7b5e9 100644 --- a/apps/admin/src/i18n/resources/zh-CN.ts +++ b/apps/admin/src/i18n/resources/zh-CN.ts @@ -1506,6 +1506,28 @@ export const zhCN = { 'readers.detail.verified': '已验证', 'readers.empty.search': '没有匹配的读者。', 'readers.empty.title': '暂无读者', + 'readers.membership.action.extend': '续期', + 'readers.membership.action.grant': '授予', + 'readers.membership.action.revoke': '撤销', + 'readers.membership.filter.all': '全部会员状态', + 'readers.membership.filter.label': '会员状态', + 'readers.membership.grant.expiresAtLabel': '到期时间', + 'readers.membership.grant.planLabel': '套餐', + 'readers.membership.grant.submit': '保存', + 'readers.membership.grant.title': '授予会员', + 'readers.membership.periodEnd': '将于 {date} 续期/到期', + 'readers.membership.plan.monthly': '月付', + 'readers.membership.plan.yearly': '年付', + 'readers.membership.providerManagedHint': + '由支付服务商托管,请在服务商后台管理套餐或取消订阅。', + 'readers.membership.revoke.desc': '确定撤销 {name} 的手动会员授予?', + 'readers.membership.revoke.title': '撤销会员', + 'readers.membership.section.title': '会员', + 'readers.membership.status.active': '生效中', + 'readers.membership.status.cancelled': '已取消', + 'readers.membership.status.expired': '已过期', + 'readers.membership.status.none': '无会员', + 'readers.membership.status.onHold': '已暂停', 'readers.refresh': '刷新', 'readers.revokeOwner.desc': '确定撤销 {name} 的站长权限?', 'readers.revokeOwner.title': '撤销站长', @@ -1521,6 +1543,10 @@ export const zhCN = { 'readers.title': '读者', 'readers.toast.banFailed': '封禁读者失败', 'readers.toast.banned': '已封禁读者', + 'readers.toast.membershipGrantFailed': '授予会员失败', + 'readers.toast.membershipGranted': '已授予会员', + 'readers.toast.membershipRevokeFailed': '撤销会员失败', + 'readers.toast.membershipRevoked': '已撤销会员', 'readers.toast.revokeFailed': '撤销站长失败', 'readers.toast.revoked': '已撤销站长', 'readers.toast.transferFailed': '转让站长失败', diff --git a/apps/admin/src/query/keys.ts b/apps/admin/src/query/keys.ts index 14df8bea265..62ae4e67764 100644 --- a/apps/admin/src/query/keys.ts +++ b/apps/admin/src/query/keys.ts @@ -196,6 +196,7 @@ export const adminQueryKeys = { detail: (id: string) => ['readers', 'detail', id] as const, listRoot: ['readers', 'list'] as const, list: (params: { + membershipStatus: string page: number role: string search: string From 6d12a269ec3e9565efdb0a1dd7ffb575ade24624 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 21:21:31 +0800 Subject: [PATCH 15/37] fix(reader): join membership into single-reader detail query GET /readers/:id never joined memberships, so the response always carried membership:null; any background refetch of the admin reader detail page (staleTime 30s, refetchOnWindowFocus) wiped the badge and wrongly re-showed the Grant action for provider-managed members. findByIdDetailed now mirrors list()'s membership join/mapping, making the server authoritative and removing the dead client-side fallback merge in ReaderDetailRoute. Also drops the JSDoc on ReaderModel.membership and makes the field non-optional. --- apps/admin/src/api/readers.ts | 6 +----- .../readers/components/ReaderDetailRoute.tsx | 7 +------ .../src/modules/reader/reader.repository.ts | 19 +++++++++++++++++-- .../reader/reader.repository.pg.e2e.spec.ts | 14 ++++++++++++++ 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/admin/src/api/readers.ts b/apps/admin/src/api/readers.ts index 325b2e171b7..1c923188cfd 100644 --- a/apps/admin/src/api/readers.ts +++ b/apps/admin/src/api/readers.ts @@ -33,11 +33,7 @@ export interface ReaderModel { createdAt: string updatedAt: string | null lastLoginAt: string | null - /** - * Only present on list rows (`GET /readers`). The single-reader detail - * endpoint (`GET /readers/:id`) does not include membership. - */ - membership?: ReaderMembership | null + membership: ReaderMembership | null } export interface ReaderStats { diff --git a/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx b/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx index 5c3c7173115..f10860230a0 100644 --- a/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx +++ b/apps/admin/src/features/readers/components/ReaderDetailRoute.tsx @@ -57,18 +57,13 @@ export function ReaderDetailRoute() { return } - const readerWithMembership: ReaderModel = - reader.membership !== undefined - ? reader - : { ...reader, membership: initialReader?.membership ?? null } - return ( ) } diff --git a/apps/core/src/modules/reader/reader.repository.ts b/apps/core/src/modules/reader/reader.repository.ts index 68b96beff5d..a7d13adebd5 100644 --- a/apps/core/src/modules/reader/reader.repository.ts +++ b/apps/core/src/modules/reader/reader.repository.ts @@ -295,14 +295,29 @@ export class ReaderRepository extends BaseRepository { const [row] = await this.db .select({ ...readerSelection, + ...membershipSelection, lastLoginAt, }) .from(readers) .leftJoin(sessions, eq(sessions.userId, readers.id)) + .leftJoin(memberships, eq(memberships.readerId, readers.id)) .where(eq(readers.id, id)) - .groupBy(readers.id) + .groupBy( + readers.id, + memberships.id, + memberships.status, + memberships.plan, + memberships.provider, + memberships.currentPeriodEnd, + ) .limit(1) - return row ? { ...mapRow(row), lastLoginAt: row.lastLoginAt } : null + return row + ? { + ...mapRow(row), + lastLoginAt: row.lastLoginAt, + membership: mapMembership(row), + } + : null } async setBanned( diff --git a/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts b/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts index f32105f6a1a..add10c95212 100644 --- a/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts +++ b/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts @@ -160,4 +160,18 @@ describe('ReaderRepository membership summary + filter (real PG)', () => { }) expect(result.data.map((row) => row.id)).toEqual([noMembershipReaderId]) }) + + it('carries a membership summary on the single-reader detail fetch', async () => { + const member = await readerRepository.findByIdDetailed(activeReaderId) + expect(member?.membership).toEqual({ + status: 'active', + plan: 'monthly', + provider: 'dodo', + currentPeriodEnd: future, + }) + + const nonMember = + await readerRepository.findByIdDetailed(noMembershipReaderId) + expect(nonMember?.membership).toBeNull() + }) }) From 2f6c331a8e145ec0fc8731088f1dec68a5dd6e8e Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 21:40:34 +0800 Subject: [PATCH 16/37] fix(membership): entitlement-keyed cache, retryable webhook apply, bounded raw body, readerId validation - cache.interceptor: bypass shared cache read/write and force private cache-control for any reader-identified request, not only admins; also skip the cache write whenever the response carries paywall.locked:false as a defense-in-depth guard against identity-detection regressions. - membership.service: on a webhook insert conflict, look up the existing event row and re-apply membership state when processed_at is still null, so a delivery that fails mid-apply is retried instead of silently swallowed. - fastify.adapter: cap raw-body buffering on the membership webhook route at 1 MiB (413 once exceeded) and match on the parsed path instead of the raw URL so a query string can't spoof or dodge the route match. - membership.controller: validate the readerId path param via the existing EntityIdDto-style Zod pattern instead of a bare string, so a malformed id is rejected before reaching the service layer. --- .../src/common/adapters/fastify.adapter.ts | 40 +++- .../common/interceptors/cache.interceptor.ts | 13 +- .../membership/membership.controller.ts | 17 +- .../modules/membership/membership.service.ts | 17 +- .../adapters/fastify-adapter-raw-body.spec.ts | 107 +++++++++ .../interceptors/cache.interceptor.spec.ts | 212 ++++++++++++++++++ .../membership.controller.e2e-spec.ts | 24 ++ .../membership/membership.service.spec.ts | 56 +++++ 8 files changed, 471 insertions(+), 15 deletions(-) create mode 100644 apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts create mode 100644 apps/core/test/src/common/interceptors/cache.interceptor.spec.ts diff --git a/apps/core/src/common/adapters/fastify.adapter.ts b/apps/core/src/common/adapters/fastify.adapter.ts index 20604b46766..df1588bdb70 100644 --- a/apps/core/src/common/adapters/fastify.adapter.ts +++ b/apps/core/src/common/adapters/fastify.adapter.ts @@ -69,20 +69,50 @@ app.register(FastifyMultipart, { }) const MEMBERSHIP_WEBHOOK_PATH_SEGMENT = '/membership/webhook/' +const MEMBERSHIP_WEBHOOK_RAW_BODY_LIMIT = 1024 * 1024 -app.getInstance().addHook('preParsing', async (request, _reply, payload) => { - if (!request.url.includes(MEMBERSHIP_WEBHOOK_PATH_SEGMENT)) { - return payload - } +export function isMembershipWebhookRequest(url: string): boolean { + const path = url.split('?', 1)[0] + return path.includes(MEMBERSHIP_WEBHOOK_PATH_SEGMENT) +} +export async function membershipWebhookRawBodyPreParsingHook( + request: FastifyRequest, + reply: { code: (statusCode: number) => { send: (payload: unknown) => void } }, + payload: AsyncIterable, +): Promise { const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of payload) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + total += buf.byteLength + + if (total > MEMBERSHIP_WEBHOOK_RAW_BODY_LIMIT) { + reply.code(413).send({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: 'Request body exceeds the allowed size', + }, + }) + return Readable.from(Buffer.alloc(0)) + } + + chunks.push(buf) } + const rawBody = Buffer.concat(chunks) Object.assign(request, { rawBody }) return Readable.from(rawBody) +} + +app.getInstance().addHook('preParsing', async (request, reply, payload) => { + if (!isMembershipWebhookRequest(request.url)) { + return payload + } + + return membershipWebhookRawBodyPreParsingHook(request, reply, payload) }) app.getInstance().addHook('onRequest', (request, reply, done) => { diff --git a/apps/core/src/common/interceptors/cache.interceptor.ts b/apps/core/src/common/interceptors/cache.interceptor.ts index c5b7c676ab3..e34935e7717 100644 --- a/apps/core/src/common/interceptors/cache.interceptor.ts +++ b/apps/core/src/common/interceptors/cache.interceptor.ts @@ -46,7 +46,12 @@ export class HttpCacheInterceptor implements NestInterceptor { context.getHandler(), ) - if ((request.hasAdminAccess ?? request.isAuthenticated) && !cacheOptions?.force) { + const hasIdentity = + request.hasAdminAccess || + request.hasReaderIdentity || + request.isAuthenticated + + if (hasIdentity && !cacheOptions?.force) { this.setPrivateCacheHeader(res) return call$ } @@ -87,7 +92,7 @@ export class HttpCacheInterceptor implements NestInterceptor { ? of(value) : call$.pipe( tap((response) => { - if (response) { + if (response && !this.isEntitledFullContent(response)) { this.cacheManager.set(key, response, ttl * 1000) } this.setCacheHeader(res, ttl) @@ -99,6 +104,10 @@ export class HttpCacheInterceptor implements NestInterceptor { } } + private isEntitledFullContent(response: any): boolean { + return response?.meta?.paywall?.locked === false + } + private setPrivateCacheHeader(res: FastifyReply) { if (res.raw.statusCode !== 200) return const cacheValue = 'private, max-age=0, no-cache, no-store, must-revalidate' diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 15d615e8e13..5f97eda47f5 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -20,6 +20,7 @@ import { ReaderAuth } from '~/common/decorators/reader-auth.decorator' import { AppErrorCode, createAppException } from '~/common/errors' import { withMeta } from '~/common/response/envelope.types' import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { zEntityId } from '~/common/zod' import type { FastifyBizRequest } from '~/transformers/get-req.transformer' import type { SessionUser } from '../auth/auth.types' @@ -45,6 +46,11 @@ const MembersListQuerySchema = z.object({ }) class MembersListQueryDto extends createZodDto(MembersListQuerySchema) {} +const ReaderIdParamSchema = z.object({ + readerId: zEntityId, +}) +class ReaderIdParamDto extends createZodDto(ReaderIdParamSchema) {} + const assertNotDemoMode = () => { if (DEMO_MODE) { throw createAppException(AppErrorCode.DEMO_FORBIDDEN) @@ -131,11 +137,8 @@ export class MembershipController { @Auth() @Put('/members/:readerId') - async grant( - @Param('readerId') readerId: string, - @Body() body: ManualGrantDto, - ) { - return this.membershipService.grantManual(readerId, { + async grant(@Param() params: ReaderIdParamDto, @Body() body: ManualGrantDto) { + return this.membershipService.grantManual(params.readerId, { plan: body.plan, expiresAt: body.expiresAt, }) @@ -143,7 +146,7 @@ export class MembershipController { @Auth() @Delete('/members/:readerId') - async revoke(@Param('readerId') readerId: string) { - return this.membershipService.revokeManual(readerId) + async revoke(@Param() params: ReaderIdParamDto) { + return this.membershipService.revokeManual(params.readerId) } } diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 25f1445e16f..cf6b25ac865 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -40,7 +40,22 @@ export class MembershipService { type: event.type, payload: event, }) - if (!webhookEventRow) return { applied: false } + + if (!webhookEventRow) { + const existingRow = + await this.billingWebhookEventRepository.findByProviderAndEventId( + event.provider, + event.eventId, + ) + if (!existingRow || existingRow.processedAt) return { applied: false } + + await this.applyMembershipState(event) + await this.billingWebhookEventRepository.markProcessed( + existingRow.id, + new Date(), + ) + return { applied: true } + } await this.applyMembershipState(event) await this.billingWebhookEventRepository.markProcessed( diff --git a/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts b/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts new file mode 100644 index 00000000000..e73c8b160fd --- /dev/null +++ b/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + isMembershipWebhookRequest, + membershipWebhookRawBodyPreParsingHook, +} from '~/common/adapters/fastify.adapter' + +const createPayload = (chunks: Buffer[]) => ({ + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk + } + }, +}) + +const createReply = () => { + const sent: { statusCode?: number; payload?: unknown } = {} + return { + code: vi.fn((statusCode: number) => { + sent.statusCode = statusCode + return { + send: vi.fn((payload: unknown) => { + sent.payload = payload + }), + } + }), + sent, + } +} + +describe('isMembershipWebhookRequest', () => { + it('matches the membership webhook path prefix', () => { + expect(isMembershipWebhookRequest('/membership/webhook/dodo')).toBe(true) + }) + + it('matches even when a query string is present', () => { + expect( + isMembershipWebhookRequest( + '/membership/webhook/dodo?foo=/membership/webhook/', + ), + ).toBe(true) + }) + + it('does not match an unrelated path that merely contains the segment in its query string', () => { + expect( + isMembershipWebhookRequest('/other/route?next=/membership/webhook/dodo'), + ).toBe(false) + }) + + it('does not match unrelated paths', () => { + expect(isMembershipWebhookRequest('/posts/1')).toBe(false) + }) +}) + +describe('membershipWebhookRawBodyPreParsingHook', () => { + it('buffers the full body and attaches it as request.rawBody', async () => { + const request: any = {} + const reply = createReply() + const payload = createPayload([ + Buffer.from('{"eventId":'), + Buffer.from('"evt_1"}'), + ]) + + const stream = await membershipWebhookRawBodyPreParsingHook( + request, + reply as any, + payload as any, + ) + + const collected: Buffer[] = [] + for await (const chunk of stream) { + collected.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + expect(Buffer.concat(collected).toString('utf8')).toBe( + '{"eventId":"evt_1"}', + ) + expect(request.rawBody.toString('utf8')).toBe('{"eventId":"evt_1"}') + expect(reply.code).not.toHaveBeenCalled() + }) + + it('aborts with 413 once the accumulated body exceeds 1 MiB', async () => { + const request: any = {} + const reply = createReply() + const oneMib = 1024 * 1024 + const chunks = [Buffer.alloc(oneMib, 'a'), Buffer.alloc(16, 'b')] + const payload = createPayload(chunks) + + const stream = await membershipWebhookRawBodyPreParsingHook( + request, + reply as any, + payload as any, + ) + + expect(reply.code).toHaveBeenCalledWith(413) + expect(reply.sent.payload).toMatchObject({ + error: { code: 'PAYLOAD_TOO_LARGE' }, + }) + expect(request.rawBody).toBeUndefined() + + const collected: Buffer[] = [] + for await (const chunk of stream) { + collected.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + expect(Buffer.concat(collected).length).toBe(0) + }) +}) diff --git a/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts b/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts new file mode 100644 index 00000000000..e268fa312a0 --- /dev/null +++ b/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts @@ -0,0 +1,212 @@ +import type { CallHandler, ExecutionContext } from '@nestjs/common' +import { firstValueFrom, of } from 'rxjs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/global/env.global', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isTest: false } +}) + +const { REDIS } = await import('~/app.config') +const { HttpCacheInterceptor } = + await import('~/common/interceptors/cache.interceptor') + +interface FakeReply { + raw: { statusCode: number } + headers: Record + header: (name: string, value: string) => void + getHeader: (name: string) => string | undefined +} + +const createReply = (): FakeReply => { + const headers: Record = {} + return { + raw: { statusCode: 200 }, + headers, + header(name, value) { + headers[name] = value + }, + getHeader(name) { + return headers[name] + }, + } +} + +const createContext = (request: any, reply: FakeReply): ExecutionContext => + ({ + getHandler: () => function handler() {}, + getClass: () => class {}, + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => reply, + }), + }) as unknown as ExecutionContext + +const createHandler = (value: unknown): CallHandler => ({ + handle: () => of(value), +}) + +const createInterceptor = (store: Map) => { + const cacheManager = { + get: vi.fn(async (key: string) => store.get(key)), + set: vi.fn(async (key: string, value: unknown) => { + store.set(key, value) + }), + } + const reflector = { get: vi.fn(() => undefined) } + const httpAdapterHost = { + httpAdapter: { getRequestMethod: () => 'GET' }, + } + const interceptor = new HttpCacheInterceptor( + cacheManager as any, + reflector as any, + httpAdapterHost as any, + ) + return { interceptor, cacheManager } +} + +const baseRequest = (overrides: Record = {}) => ({ + method: 'GET', + url: '/posts/paywalled-post', + query: {}, + hasAdminAccess: false, + hasReaderIdentity: false, + isAuthenticated: false, + ...overrides, +}) + +describe('HttpCacheInterceptor entitlement-keyed caching', () => { + const originalDisableApiCache = REDIS.disableApiCache + + beforeEach(() => { + REDIS.disableApiCache = false + }) + + afterEach(() => { + REDIS.disableApiCache = originalDisableApiCache + }) + + it('never writes an entitled member response to the shared cache and sends private cache-control', async () => { + const store = new Map() + const { interceptor } = createInterceptor(store) + const reply = createReply() + const request = baseRequest({ hasReaderIdentity: true }) + const memberResponse = { + data: { title: 'Full premium content' }, + meta: { paywall: { locked: false } }, + } + + const observable = await interceptor.intercept( + createContext(request, reply), + createHandler(memberResponse), + ) + const result = await firstValueFrom(observable as any) + + expect(result).toEqual(memberResponse) + expect(store.size).toBe(0) + expect(reply.headers['cache-control']).toContain('private') + expect(reply.headers['cdn-cache-control']).toContain('private') + }) + + it('does not replay a member-primed cache entry to an anonymous visitor', async () => { + const store = new Map() + + const { interceptor: memberInterceptor } = createInterceptor(store) + await firstValueFrom( + (await memberInterceptor.intercept( + createContext(baseRequest({ hasReaderIdentity: true }), createReply()), + createHandler({ + data: { title: 'Full premium content' }, + meta: { paywall: { locked: false } }, + }), + )) as any, + ) + + expect(store.size).toBe(0) + + const { interceptor: anonInterceptor, cacheManager } = + createInterceptor(store) + const anonHandler = vi.fn(() => + of({ data: { title: 'teaser' }, meta: { paywall: { locked: true } } }), + ) + const anonResult = await firstValueFrom( + (await anonInterceptor.intercept( + createContext(baseRequest(), createReply()), + { + handle: anonHandler, + }, + )) as any, + ) + + expect(cacheManager.get).toHaveBeenCalled() + expect(anonHandler).toHaveBeenCalled() + expect(anonResult).toEqual({ + data: { title: 'teaser' }, + meta: { paywall: { locked: true } }, + }) + }) + + it('caches an anonymous locked/teaser response and replays it on a subsequent anonymous request', async () => { + const store = new Map() + const { interceptor: firstInterceptor } = createInterceptor(store) + const teaserResponse = { + data: { title: 'teaser' }, + meta: { paywall: { locked: true } }, + } + + await firstValueFrom( + (await firstInterceptor.intercept( + createContext(baseRequest(), createReply()), + createHandler(teaserResponse), + )) as any, + ) + + expect(store.size).toBe(1) + + const { interceptor: secondInterceptor, cacheManager } = + createInterceptor(store) + const secondResult = await firstValueFrom( + (await secondInterceptor.intercept( + createContext(baseRequest(), createReply()), + createHandler({ data: { title: 'should-not-be-returned' } }), + )) as any, + ) + + expect(cacheManager.get).toHaveBeenCalled() + expect(secondResult).toEqual(teaserResponse) + }) + + it('skips the cache write for an entitled response even if identity detection regresses', async () => { + const store = new Map() + const { interceptor } = createInterceptor(store) + + await firstValueFrom( + (await interceptor.intercept( + createContext(baseRequest(), createReply()), + createHandler({ + data: { title: 'Full premium content' }, + meta: { paywall: { locked: false } }, + }), + )) as any, + ) + + expect(store.size).toBe(0) + }) + + it('sends private cache-control headers for any reader-identified request regardless of route', async () => { + const store = new Map() + const { interceptor, cacheManager } = createInterceptor(store) + const reply = createReply() + + await firstValueFrom( + (await interceptor.intercept( + createContext(baseRequest({ hasReaderIdentity: true }), reply), + createHandler({ data: { ok: true } }), + )) as any, + ) + + expect(cacheManager.get).not.toHaveBeenCalled() + expect(cacheManager.set).not.toHaveBeenCalled() + expect(reply.headers['cache-control']).toContain('private') + }) +}) diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index d91cdc4391f..3a1ddf79be4 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -400,6 +400,20 @@ describe('MembershipController (e2e)', () => { }) }) + it('returns 422 for a malformed readerId path param', async () => { + const res = await proxy.app.inject({ + method: 'PUT', + url: '/membership/members/not-a-valid-id', + headers: { 'test-token': '1', 'content-type': 'application/json' }, + payload: { + plan: 'monthly', + expiresAt: new Date(Date.now() + 1000 * 60).toISOString(), + }, + }) + + expect(res.statusCode).toBe(422) + }) + it('rejects manual grant/revoke callers without the owner test-token header', async () => { const res = await proxy.app.inject({ method: 'PUT', @@ -425,5 +439,15 @@ describe('MembershipController (e2e)', () => { data: { status: 'cancelled', provider: 'manual' }, }) }) + + it('returns 422 for a malformed readerId path param', async () => { + const res = await proxy.app.inject({ + method: 'DELETE', + url: '/membership/members/not-a-valid-id', + headers: { 'test-token': '1' }, + }) + + expect(res.statusCode).toBe(422) + }) }) }) diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 0839df170c4..0f23dfca44b 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -88,6 +88,62 @@ describe('MembershipService', () => { expect(membershipRepository.update).not.toHaveBeenCalled() }) + it('retries and applies state when the first delivery fails mid-apply and processed_at is still null', async () => { + const { service, billingWebhookEventRepository, membershipRepository } = + createService() + + membershipRepository.create.mockRejectedValueOnce( + new Error('transient db error'), + ) + + await expect(service.applyEvent(createEvent())).rejects.toThrow( + 'transient db error', + ) + expect(billingWebhookEventRepository.markProcessed).not.toHaveBeenCalled() + + billingWebhookEventRepository.create.mockResolvedValue(null) + billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({ + id: 'event-1' as any, + provider: 'dodo', + eventId: 'evt_1', + type: 'activated', + payload: {}, + processedAt: null, + receivedAt: now, + }) + + const result = await service.applyEvent(createEvent()) + + expect(result).toEqual({ applied: true }) + expect(membershipRepository.create).toHaveBeenCalledTimes(2) + expect(billingWebhookEventRepository.markProcessed).toHaveBeenCalledWith( + 'event-1', + expect.any(Date), + ) + }) + + it('skips retry when the existing event row is already processed', async () => { + const { service, billingWebhookEventRepository, membershipRepository } = + createService() + + billingWebhookEventRepository.create.mockResolvedValue(null) + billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({ + id: 'event-1' as any, + provider: 'dodo', + eventId: 'evt_1', + type: 'activated', + payload: {}, + processedAt: now, + receivedAt: now, + }) + + const result = await service.applyEvent(createEvent()) + + expect(result).toEqual({ applied: false }) + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.markProcessed).not.toHaveBeenCalled() + }) + it('inserts the webhook event before applying state and marks it processed', async () => { const { service, billingWebhookEventRepository, membershipRepository } = createService() From 5c5b25eb9b91bbfdf14a9a52a4b8ca412bbf353e Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 21:47:48 +0800 Subject: [PATCH 17/37] docs: add paid-articles membership design spec and implementation plan --- ...026-07-18-paid-articles-membership-plan.md | 225 ++++++++++++++++ ...6-07-18-paid-articles-membership-design.md | 248 ++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-paid-articles-membership-plan.md create mode 100644 docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md diff --git a/docs/superpowers/plans/2026-07-18-paid-articles-membership-plan.md b/docs/superpowers/plans/2026-07-18-paid-articles-membership-plan.md new file mode 100644 index 00000000000..2718b77f19b --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-paid-articles-membership-plan.md @@ -0,0 +1,225 @@ +# Paid Articles via Membership — Implementation Plan + +Spec (single source of truth, read it first): +`docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md` + +## Global Constraints + +- Follow the spec exactly. Its Decoupling architecture section (4 rules) is + binding: single entitlement entry point `EntitlementService.isActiveMember`, + truncation as pure functions, one paywall gate in the post detail + controller running AFTER enrichment/translation attach and BEFORE + `withMeta`, and all public surfaces reading content via `getPublicText`. +- Premium is lexical-only: `is_premium` must be rejected when + `contentFormat !== 'lexical'` (`PremiumRequiresLexical`). +- Entitlement rule: `status IN ('active','on_hold') AND now < current_period_end`. +- API responses follow the repo envelope rules (CLAUDE.md): bare value or + `withMeta`; errors via `AppException` subclasses with stable codes. +- Zero comments / zero JSDoc in new code (repo rule). +- Migrations are expand-only (rolling deploys). Use the `mx-migration-author` + skill conventions; run `pnpm -C apps/core run lint:migrations`. +- IDs are Snowflake bigint, serialized as strings at API boundaries. +- Lint/typecheck only the files you touched, never the whole project. +- Provider SDKs (`dodopayments`, `standardwebhooks`) may be imported ONLY + inside `modules/membership/providers/` adapter files. +- Tests: Vitest; e2e via `createE2EApp` (`test/helper/create-e2e-app.ts`) + and `startPgTestContainer` where PG is needed. + +## Task 1: Database schema, migration, repositories + +Add to `apps/core/src/database/schema/`: + +- `posts`: new column `is_premium boolean not null default false` (TS prop + `isPremium`, explicit snake_case column name, matching existing style). +- New table `memberships` and new table `billing_webhook_events` exactly as + specified in the spec's Data model section (columns, nullability, unique + constraints: `memberships.reader_id` unique, `memberships.provider_subscription_id` + unique, `billing_webhook_events (provider, event_id)` unique). + +Generate/author the expand-only SQL migration in +`apps/core/src/database/migrations/` (next sequential number, follow +existing file naming). Run `pnpm -C apps/core run lint:migrations`. + +Create `MembershipRepository` and `BillingWebhookEventRepository` extending +`BaseRepository`, registered via `repository.tokens.ts`, following the +existing repository pattern (see e.g. the post or reader repository). + +Tests: repository CRUD smoke test with `startPgTestContainer` (follow an +existing repository spec as template), asserting unique constraints fire. + +## Task 2: Lexical truncation pure helpers + +In the Lexical helper layer (near `helper.lexical.service.ts`, e.g. +`apps/core/src/processors/helper/lexical/paywall-truncate.ts` or the +directory where lexical utils live — follow existing structure): + +- `truncateLexicalContent(contentJson: string, nBlocks: number): string` — + returns Lexical JSON containing only the first N top-level blocks of the + root node. Pure function, no DI. +- `renderTeaserText(truncatedJson: string): Promise` (or sync if the + headless API allows) — regenerates teaser markdown from the truncated + state via `createHeadlessEditor()` + `allHeadlessNodes` + `$toMarkdown()`, + same pattern as `helper.lexical.service.ts`. + +Unit tests (no PG, no app): truncation keeps exactly N top-level blocks, +handles N >= block count (returns unchanged), invalid JSON throws or returns +a safe empty state (pick one behavior and test it), teaser text equals +`$toMarkdown` of the truncated state. + +## Task 3: Membership module core (services, adapter, Dodo provider, config, errors) + +New module `apps/core/src/modules/membership/`: + +- `providers/provider.interface.ts`: `PaymentProviderAdapter` and + `NormalizedBillingEvent` exactly as in the spec (including + `plan_changed`). +- `providers/dodo.provider.ts`: implements the interface with the + `dodopayments` Node SDK (`checkoutSessions.create`, metadata carries + `readerId`) and `standardwebhooks` verification. Event mapping per spec. + Add both packages to `apps/core/package.json`. +- `membership.service.ts`: `applyEvent(event: NormalizedBillingEvent)` — + insert into `billing_webhook_events` first (skip processing when + `(provider, event_id)` already exists), upsert `memberships` state, set + `processed_at`. Also `getByReaderId`, manual grant upsert + (`provider: 'manual'`, rejected when a live provider-managed subscription + exists), manual revoke (manual rows only → `cancelled`). +- `entitlement.service.ts`: `isActiveMember(readerId): Promise` + implementing the entitlement rule. This is the ONLY export other modules + may consume. +- Configs module: add a `membership` section (`enabled`, `provider`, + `monthlyProductId`, `yearlyProductId`) to `configs.schema.ts` following + existing section patterns. Secrets `DODO_API_KEY` / `DODO_WEBHOOK_KEY` + read from env via `app.config.ts` conventions. +- Error codes: add `MembershipRequired`, `WebhookVerifyFailed`, + `MembershipProviderNotConfigured`, `PremiumRequiresLexical` to the error + definitions (`app-error-definitions.ts` / `ErrorCodeEnum`), statuses per + spec. + +Unit tests with mocked repositories and mocked SDK: applyEvent idempotency, +state transitions (activated/renewed/on_hold/cancelled/plan_changed), +entitlement matrix (active / on_hold in grace / cancelled / expired +period / none), manual grant rejection when provider subscription live. + +## Task 4: Membership HTTP API + +Controller(s) in `apps/core/src/modules/membership/` per the spec API table: + +- `POST /membership/checkout` — `@Auth` reader; body `{ plan }` (Zod); + throws `MembershipProviderNotConfigured` when configs incomplete; returns + `{ checkoutUrl }`. +- `GET /membership/status` — `@Auth` reader; current reader's membership + (status, plan, provider, currentPeriodEnd) or a `none` shape. +- `POST /membership/webhook/:provider` — public; raw body for signature + verification (find how the app exposes raw body with Fastify; if a raw + body plugin/config is needed, add it narrowly for this route); + `WebhookVerifyFailed` (400) on bad signature; then + `membershipService.applyEvent`. +- `GET /membership/members` — owner (`@Auth` with admin/owner guard used by + other admin endpoints); paginated member list. +- `PUT /membership/members/:readerId` — owner; body `{ plan, expiresAt }`; + manual grant/extend per spec. +- `DELETE /membership/members/:readerId` — owner; manual revoke per spec. +- Checkout + webhook mutation paths throw `BanInDemoExcpetion` in demo mode + (follow existing demo-guard usage). + +E2E tests via `createE2EApp` + `startPgTestContainer` with the provider +adapter mocked: each endpoint's happy path, webhook idempotent double +delivery, signature failure 400, checkout without config → +`MembershipProviderNotConfigured`, manual grant/revoke flows, auth guards +(anonymous 401 on reader routes, reader 403 on owner routes). + +## Task 5: Paywall enforcement in post serving + +- Write-side validation: setting `isPremium: true` on a post whose + `contentFormat !== 'lexical'` throws `PremiumRequiresLexical` (400) — + enforce in post schema/service on create and update. Add `isPremium` to + `post.schema.ts` write schema. +- `meta.types.ts`: add `paywall: z.object({ locked: z.boolean(), previewBlocks: z.number().optional() }).optional()` + to `PostResponseMetaSchema`; add `.paywall()` to `PostMetaBuilder`. +- Post detail handlers (`getByCateAndSlug` / `getById` in + `post.controller.ts`): after enrichment/translation attach and before + `withMeta`, when `post.isPremium` and requester is not owner and + `!entitlementService.isActiveMember(readerId)`: replace `content` with + `truncateLexicalContent(content, N)` (N = `meta.paywall.previewBlocks` + ?? 3), replace `text` with the regenerated teaser, set + `metaBuilder.paywall({ locked: true, previewBlocks: N })`. Entitled or + owner: `paywall({ locked: false })` only when the post is premium. + Mirror the note secret pattern (`note.controller.ts:164-196`) for how the + reader identity is resolved (`@HasAdminAccess()` etc.). +- Caching: if the post detail path is Redis-cached, add the entitlement + dimension (`locked`/`unlocked`) to the cache key for premium posts. If + detail responses are not cached today, state that in your report instead + of adding caching. + +E2E tests: entitlement matrix over a premium lexical post (anonymous / +non-member reader / active member / on_hold member / expired / owner), +truncation applied to both `content` and `text`, `meta.paywall` shape in +both states, non-premium post has no `paywall` meta, `isPremium` on a +markdown post → 400. + +## Task 6: Public-surface accessor and wiring + +- `getPublicText(post)` helper (location: post module or shared, wherever + it avoids dependency cycles): premium → teaser (truncate + render, using + Task 2 helpers with the post's `previewBlocks`), else full `text`. +- Wire it into every public emitter of post content: RSS/feed generation, + search index (if the app indexes full text — check the search module), + gateway/websocket broadcast of post create/update events. AI summary + generation intentionally keeps reading full text — do not change it. +- Audit: grep for direct `\.text` consumers of posts feeding public + surfaces; list in your report any you deliberately left unchanged and why. + +Tests: unit test `getPublicText` (premium vs free); one test per wired +surface asserting a premium post emits teaser only (mock what is heavy). + +## Task 7: Reader list membership summary and filter + +- Reader list endpoint (`GET /readers`, reader module): left-join + `memberships`; each row gains `membership: { status, plan, provider, currentPeriodEnd } | null`. +- `ReaderListQuerySchema` gains `membershipStatus` filter: + `active | on_hold | cancelled | expired | none` (`expired` = has row but + period end passed; `none` = no row). Filtering in SQL. +- Update reader views schema if the list passes through one. + +Tests: e2e or repository-level with PG container — list carries membership +summary; each filter value returns the correct subset. + +## Task 8: api-client types and endpoints + +In `packages/api-client`: typed `membership` controller/endpoints +(`checkout(plan)`, `status()`) following the existing per-resource client +structure, and `meta.paywall` type on post detail responses. Follow the +package's existing patterns and tests (if the package has controller +tests, add matching ones). Build the package (its own build only) to verify +types compile. + +## Task 9: Admin post editor paywall controls + +`apps/admin/src/features/write/components/WriteRouteViewsContent.tsx` (and +whatever preset/store carries post form state): add a premium toggle and a +`previewBlocks` number input (default 3, min 1), visible for posts only, +enabled only when the editor is in Lexical mode. Persist to `isPremium` and +`meta.paywall.previewBlocks`. Follow the file's existing field patterns +(how `copyright`/`pin` are wired). Scope: minimal diff in this large file. + +Verify: lint/typecheck touched files; if the admin app has component tests +for this area, add one; otherwise a manual reasoning note in the report. + +## Task 10: Admin readers page membership UI + +`apps/admin/src/features/readers/`: + +- `ReaderListRow`: membership status badge (active / on_hold / cancelled / + expired / none, with plan + provider). +- `ReadersToolbar`: `membershipStatus` filter wired to the extended list + query (Task 7 param). +- Reader detail (`readers/[id]` route content): membership block (status, + plan, provider, period end) with owner actions: grant/extend modal + (pattern of `BanReaderModal`; fields plan + expiry date; calls + `PUT /membership/members/:readerId`) shown when no live provider-managed + subscription; revoke button (DELETE) for manual grants only; + provider-managed rows show a hint to manage in the provider dashboard. +- Extend the admin's API layer for the new endpoints following its existing + data-layer patterns. + +Verify: lint/typecheck touched files; existing test conventions if any. diff --git a/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md b/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md new file mode 100644 index 00000000000..23071de2ac6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md @@ -0,0 +1,248 @@ +# Paid Articles via Membership Subscription — Design + +Date: 2026-07-18 +Status: Approved (brainstorming) + +## Goal + +Let the blog owner mark individual posts as premium. Non-members see a free +preview (the first N Lexical blocks); readers with an active membership +subscription see the full article. Membership is a single tier with monthly +and yearly billing, paid through a pluggable payment provider (Dodo Payments +first; Creem / Lemon Squeezy / Stripe as future adapters). + +## Scope + +- Content type: **posts only**. Notes and pages are out of scope. +- Format: **only `contentFormat === 'lexical'` posts can be premium.** + Schema/service validation rejects `is_premium` on markdown posts. +- Identity: members are Better Auth `reader` accounts. Entitlement is bound + to the reader, not to an email or magic link. +- One active payment provider at a time, selected in the configs module. +- No reconciliation cron in v1; state is webhook-driven. (Personal-blog + volume; a manual re-sync can be added later if drift is observed.) + +## Data model + +### `posts` (existing table, expand-only migration) + +- Add `is_premium boolean not null default false`. +- Per-post paywall tuning lives in the existing free-form `meta` JSONB bag: + `meta.paywall = { previewBlocks?: number }` (default 3). No migration needed. + +### `memberships` (new) + +| column | type | notes | +| ------------------------ | ----------- | ---------------------------------------------- | +| id | bigint PK | Snowflake | +| reader_id | bigint | FK reader.id, unique | +| provider | text | 'dodo' / 'creem' / 'lemonsqueezy' / 'stripe' / 'manual' | +| provider_customer_id | text, null | null for manual grants | +| provider_subscription_id | text, null | unique; null for manual grants | +| plan | text | 'monthly' / 'yearly' | +| status | text | 'active' / 'on_hold' / 'cancelled' / 'expired' | +| current_period_end | timestamptz | see entitlement rule below | +| created_at / updated_at | timestamptz | | + +**Entitlement rule**: entitled when `status IN ('active', 'on_hold')` AND +`now < current_period_end`. `on_hold` (payment retry in progress at the +provider) is a grace period — access is retained until the provider settles +to `cancelled`. + +### `billing_webhook_events` (new) + +| column | type | notes | +| ------------ | ----------- | --------------------------------------- | +| id | bigint PK | Snowflake | +| provider | text | unique together with event_id | +| event_id | text | provider's event id, `(provider, event_id)` unique | +| type | text | raw provider event name | +| payload | jsonb | raw body, kept for audit/replay | +| processed_at | timestamptz | null = not yet processed, retryable | +| received_at | timestamptz | | + +## Payment provider adapter layer + +Location: `apps/core/src/modules/membership/providers/`. + +```ts +interface PaymentProviderAdapter { + createCheckout(input: { reader; plan: 'monthly' | 'yearly' }): Promise<{ checkoutUrl: string }> + verifyAndParseWebhook(rawBody: Buffer | string, headers): Promise + getPortalUrl?(customerId: string): Promise +} + +interface NormalizedBillingEvent { + eventId: string + provider: string + type: 'activated' | 'renewed' | 'on_hold' | 'cancelled' | 'plan_changed' + customerId: string + subscriptionId: string + plan?: 'monthly' | 'yearly' + currentPeriodEnd: Date + readerId: string +} +``` + +- `readerId` round-trips through checkout metadata (supported by all four + providers) so webhooks can be attributed to a reader. +- v1 implements `DodoProvider` only, using the `dodopayments` Node SDK + (`checkoutSessions.create`) and `standardwebhooks` signature verification. + Dodo events map: `subscription.active` → activated, `subscription.renewed` + → renewed, `subscription.on_hold` → on_hold, `subscription.cancelled` / + `subscription.expired` → cancelled, `subscription.plan_changed` → + plan_changed (updates the `plan` column only). +- Provider SDKs are imported **only inside adapter files**. Core membership + logic consumes `NormalizedBillingEvent` exclusively. +- Adding a provider = one new adapter file; core membership logic unchanged. + +## Decoupling architecture + +The paywall touches many surfaces; coupling is contained by four rules: + +1. **Single entitlement entry point.** The membership module exports one + method other modules may use: `EntitlementService.isActiveMember(readerId)`. + The post side knows nothing about providers, tables, or billing state. + Dependency direction is one-way: post → membership. Deleting the + membership module leaves the post side with one call site and one gate + function. +2. **Truncation is a pure function.** `truncateLexicalContent(json, nBlocks)` + lives in the Lexical helper layer with no DI and no state. The teaser + `text` is regenerated from the truncated Lexical state via `$toMarkdown()` + (no separate markdown truncation path exists — premium is lexical-only). + Independently unit-testable. +3. **One gate, guaranteed ordering.** Paywall application happens at exactly + one place: the post detail controller boundary, via + `applyPaywall(postData, entitled)`. **Invariant: the gate runs AFTER + enrichment/translation attachment and BEFORE `withMeta`.** Translated + content therefore passes through the same truncation automatically — + translation leak-proofing needs no dedicated logic, only this ordering. +4. **Public surfaces read content through one accessor.** Anything that emits + post content to a public surface — search indexing, gateway/websocket + broadcasts, RSS/feed — must not read `post.text` directly; it calls + `getPublicText(post)`, which returns the teaser for premium posts and the + full text otherwise. The leakage surface collapses to one function; future + consumers inherit the guard. Insights generation also goes through it (or + omits excerpt fields). **Exception: AI summary intentionally reads the + full text — summaries are free by design.** + +## Configuration + +- Secrets via environment / `app.config.ts`: `DODO_API_KEY`, + `DODO_WEBHOOK_KEY` (per-provider naming as adapters are added). +- Configs module (admin-editable) gains a `membership` section: `enabled`, + `provider` (single choice), `monthlyProductId`, `yearlyProductId`. + +## API (new `membership` module) + +| route | auth | behavior | +| -------------------------------- | ----------- | ----------------------------------------------------------- | +| `POST /membership/checkout` | `@Auth` reader | body `{ plan }`; calls adapter.createCheckout; returns `{ checkoutUrl }` | +| `GET /membership/status` | `@Auth` reader | current reader's membership (status, plan, period end) | +| `POST /membership/webhook/:provider` | public, raw body | verify signature → insert into `billing_webhook_events` (skip if `(provider, event_id)` exists) → apply state change to `memberships` → mark `processed_at` | +| `GET /membership/members` | owner | paginated member list for admin | +| `PUT /membership/members/:readerId` | owner | manually grant/extend membership: body `{ plan, expiresAt }`; upserts a `provider: 'manual'` row with `status: 'active'`, `current_period_end: expiresAt`. Rejected if the reader has a live provider-managed subscription (manage that in the provider portal instead) | +| `DELETE /membership/members/:readerId` | owner | revoke a manual grant (sets `cancelled`); provider-managed subscriptions cannot be revoked here | + +The reader list endpoint (`GET /readers` in the reader module) is extended: +each row carries a membership summary (`status`, `plan`, `provider`, +`currentPeriodEnd`, left-joined from `memberships`), and the query schema +gains a `membershipStatus` filter (`active` / `on_hold` / `cancelled` / +`expired` / `none`). + +- Webhook route uses raw-body handling as required by signature verification. +- Checkout and webhook mutation paths throw `BanInDemoExcpetion` in demo mode. + +## Paywall enforcement (post serving) + +In the post detail handlers (`getByCateAndSlug` / `getById` in +`post.controller.ts`), mirroring the existing note secret/password pattern: + +- If `post.is_premium` and requester is not the owner and + `!isActiveMember(readerId)`: + - truncate Lexical `content` to the first N top-level blocks, + - regenerate teaser `text` from the truncated state via `$toMarkdown()`, + - set `meta.paywall = { locked: true, previewBlocks: N }` via a new + `.paywall()` method on `PostMetaBuilder` (schema added to + `PostResponseMetaSchema` in `meta.types.ts`). +- If entitled (owner or active member): full content, + `meta.paywall = { locked: false }`. +- Card/summary views are unchanged (they never carry full content). +- RSS/feed, search index, and gateway broadcasts emit teaser only for + premium posts (via `getPublicText`, see Decoupling rule 4). +- **Caching**: any Redis-cached post detail must key on the entitlement + dimension (e.g. append `locked`/`unlocked` to the cache key for premium + posts), so a truncated body is never served to a member or vice versa. + +Truncation happens server-side; full content never leaves the server for +unentitled requests. + +## Admin UI + +### Post editor + +`apps/admin/src/features/write/components/WriteRouteViewsContent.tsx`: +add a premium toggle and a `previewBlocks` number input, shown for posts +only and enabled only when the editor is in Lexical mode. The owner always +sees full content (covered by the entitlement check). + +### Readers page (`apps/admin/src/features/readers/`) + +- **List** (`ReadersRouteViewContent` / `ReaderListRow`): show a membership + status badge per reader (active / on_hold / cancelled / expired / none, + with plan and provider). Toolbar (`ReadersToolbar`) gains a + `membershipStatus` filter backed by the extended reader list query. +- **Detail** (`readers/[id]`): a membership block showing status, plan, + provider, and period end, with owner actions: + - **Grant / extend tier** — a modal (pattern of `BanReaderModal`) taking + plan and expiry date, calling `PUT /membership/members/:readerId`. + Available when the reader has no live provider-managed subscription. + - **Revoke** — calls `DELETE /membership/members/:readerId`; shown only + for manual grants. Provider-managed subscriptions display a link-out + hint to manage them in the provider dashboard. + +Future (not v1): a haklex "paywall divider" node letting the author place +the cut point manually instead of counting blocks. + +## api-client + +`packages/api-client` gains typed membership endpoints (`checkout`, +`status`) and the `meta.paywall` type on post responses. + +## Errors + +New `ErrorCodeEnum` entries following the existing `AppException` pattern: + +- `MembershipRequired` — attempting a member-only action without entitlement. +- `WebhookVerifyFailed` — signature verification failure (400). +- `MembershipProviderNotConfigured` — checkout requested while the configs + module has no provider set up. +- `PremiumRequiresLexical` — setting `is_premium` on a non-lexical post (400). + +## Testing + +- Webhook idempotency: same `(provider, event_id)` delivered twice applies + state once. +- Truncation: Lexical cut to N blocks, teaser text matches `$toMarkdown()` + of the truncated state; entitled readers and owner receive full content; + `meta.paywall` correct in both states. +- Ordering invariant: paywall gate applies after translation attachment + (translated premium content is truncated too). +- Entitlement matrix: anonymous / logged-in non-member / active member / + expired member / on_hold member (grace: entitled) / cancelled member. +- `getPublicText`: teaser for premium, full for free posts. +- Validation: `is_premium` rejected on markdown posts. +- Checkout endpoint: returns provider URL; fails with + `MembershipProviderNotConfigured` when unset; demo mode forbidden. +- Manual grant: upsert grants entitlement until expiry; revoke removes it; + grant rejected when a provider-managed subscription exists; reader list + filter by `membershipStatus` returns correct subsets. +- E2E via `createE2EApp` + `startPgTestContainer`, provider adapter mocked. + +## Out of scope (v1) + +- Multiple simultaneous providers, multiple tiers, single-article purchase. +- Reconciliation cron / provider polling. +- Refund/dispute automation (handle manually in provider dashboard; + `cancelled` webhook covers access revocation). +- Paywall divider node in the editor (v1 uses block count). From bbc8be4b16016cbc68b882b4566223371f7e6cbd Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 21:59:23 +0800 Subject: [PATCH 18/37] refactor(membership): move Dodo settings into configs module Replace DODO_* env vars / app.config.ts with an admin-editable membership.dodoApiKey/dodoWebhookKey/dodoEnvironment section, encrypted like other secret fields. DodoProvider now reads configs per call and rebuilds its client when the key or environment changes, so admin edits apply without a restart. --- apps/core/src/app.config.test.ts | 5 -- apps/core/src/app.config.ts | 5 -- .../src/modules/configs/configs.default.ts | 3 + .../src/modules/configs/configs.schema.ts | 13 +++ .../membership/providers/dodo.provider.ts | 35 ++++++-- apps/core/test/setup-global.ts | 2 - .../modules/membership/dodo.provider.spec.ts | 80 +++++++++++++++++++ ...6-07-18-paid-articles-membership-design.md | 18 ++++- 8 files changed, 137 insertions(+), 24 deletions(-) diff --git a/apps/core/src/app.config.test.ts b/apps/core/src/app.config.test.ts index 2911fcf9a9c..a511fc20907 100644 --- a/apps/core/src/app.config.test.ts +++ b/apps/core/src/app.config.test.ts @@ -59,11 +59,6 @@ export const SNOWFLAKE = { epochMs: 1746144000000, } -export const MEMBERSHIP = { - dodoApiKey: process.env.DODO_API_KEY, - dodoWebhookKey: process.env.DODO_WEBHOOK_KEY, -} - export const POSTGRES = { connectionString: process.env.PG_URL || process.env.PG_CONNECTION_STRING, host: process.env.PG_HOST || '127.0.0.1', diff --git a/apps/core/src/app.config.ts b/apps/core/src/app.config.ts index 23bba1a2708..8d7dfc9cbd5 100644 --- a/apps/core/src/app.config.ts +++ b/apps/core/src/app.config.ts @@ -386,8 +386,3 @@ export const ADMIN_UPDATE = { process.env.ADMIN_UPDATE_S3_BASE_URL || 'https://admin-r2.innei.dev') as string, } - -export const MEMBERSHIP = { - dodoApiKey: process.env.DODO_API_KEY as string | undefined, - dodoWebhookKey: process.env.DODO_WEBHOOK_KEY as string | undefined, -} diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 7cf4a8ab8b7..ceb123dba35 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -172,5 +172,8 @@ export const generateDefaultConfig: () => IConfig = () => ({ provider: 'dodo', monthlyProductId: '', yearlyProductId: '', + dodoApiKey: '', + dodoWebhookKey: '', + dodoEnvironment: 'live_mode', }, }) diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 32ae1fca8cb..124abb09d64 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -941,6 +941,19 @@ export const MembershipSchema = section('Membership', { z.string().optional(), 'Yearly plan product ID', ), + dodoApiKey: field.password(z.string().optional(), 'Dodo API key'), + dodoWebhookKey: field.password( + z.string().optional(), + 'Dodo webhook signing key', + ), + dodoEnvironment: field.select( + z.enum(['test_mode', 'live_mode']).optional(), + 'Dodo environment', + [ + { label: 'Live mode', value: 'live_mode' }, + { label: 'Test mode', value: 'test_mode' }, + ], + ), }) export class MembershipDto extends createZodDto(MembershipSchema) {} export type MembershipConfig = z.infer diff --git a/apps/core/src/modules/membership/providers/dodo.provider.ts b/apps/core/src/modules/membership/providers/dodo.provider.ts index c974f55bc14..cc06bc4e01f 100644 --- a/apps/core/src/modules/membership/providers/dodo.provider.ts +++ b/apps/core/src/modules/membership/providers/dodo.provider.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common' import DodoPayments from 'dodopayments' import { Webhook } from 'standardwebhooks' -import { MEMBERSHIP } from '~/app.config' import { AppErrorCode, createAppException } from '~/common/errors' import { ConfigsService } from '../../configs/configs.service' @@ -54,14 +53,24 @@ const planFromInterval = ( @Injectable() export class DodoProvider implements PaymentProviderAdapter { private client: DodoPayments | null = null + private cachedApiKey: string | null = null + private cachedEnvironment: 'test_mode' | 'live_mode' | null = null constructor(private readonly configsService: ConfigsService) {} - private getClient(): DodoPayments { - if (!MEMBERSHIP.dodoApiKey) { - throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + private getClient( + apiKey: string, + environment: 'test_mode' | 'live_mode', + ): DodoPayments { + if ( + !this.client || + this.cachedApiKey !== apiKey || + this.cachedEnvironment !== environment + ) { + this.client = new DodoPayments({ bearerToken: apiKey, environment }) + this.cachedApiKey = apiKey + this.cachedEnvironment = environment } - this.client ??= new DodoPayments({ bearerToken: MEMBERSHIP.dodoApiKey }) return this.client } @@ -79,7 +88,16 @@ export class DodoProvider implements PaymentProviderAdapter { throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) } - const session = await this.getClient().checkoutSessions.create({ + if (!membershipConfig.dodoApiKey) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + const client = this.getClient( + membershipConfig.dodoApiKey, + membershipConfig.dodoEnvironment, + ) + + const session = await client.checkoutSessions.create({ product_cart: [{ product_id: productId, quantity: 1 }], metadata: { readerId: input.reader.id }, customer: input.reader.email @@ -98,13 +116,14 @@ export class DodoProvider implements PaymentProviderAdapter { rawBody: Buffer | string, headers: Record, ): Promise { - if (!MEMBERSHIP.dodoWebhookKey) { + const membershipConfig = await this.configsService.get('membership') + if (!membershipConfig.dodoWebhookKey) { throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) } const payload = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8') - const webhook = new Webhook(MEMBERSHIP.dodoWebhookKey) + const webhook = new Webhook(membershipConfig.dodoWebhookKey) let event: DodoSubscriptionEvent try { diff --git a/apps/core/test/setup-global.ts b/apps/core/test/setup-global.ts index 3da0cf13dc1..da801d92bfc 100644 --- a/apps/core/test/setup-global.ts +++ b/apps/core/test/setup-global.ts @@ -6,8 +6,6 @@ process.env.NODE_ENV ??= 'development' process.env.MX_ENCRYPT_KEY ??= '593f62860255feb0a914534a43814b9809cc7534da7f5485cd2e3d3c8609acab' process.env.SNOWFLAKE_WORKER_ID ??= '1' -process.env.DODO_API_KEY ??= 'test-dodo-api-key' -process.env.DODO_WEBHOOK_KEY ??= 'test-dodo-webhook-key' vi.mock('~/utils/schedule.util', () => ({ scheduleManager: { diff --git a/apps/core/test/src/modules/membership/dodo.provider.spec.ts b/apps/core/test/src/modules/membership/dodo.provider.spec.ts index c9b5d9a6a95..30b4777ea1a 100644 --- a/apps/core/test/src/modules/membership/dodo.provider.spec.ts +++ b/apps/core/test/src/modules/membership/dodo.provider.spec.ts @@ -31,6 +31,9 @@ describe('DodoProvider', () => { provider: 'dodo', monthlyProductId: 'prod_monthly', yearlyProductId: 'prod_yearly', + dodoApiKey: 'test-dodo-api-key', + dodoWebhookKey: 'test-dodo-webhook-key', + dodoEnvironment: 'test_mode', }), } }) @@ -77,6 +80,63 @@ describe('DodoProvider', () => { }), ) }) + + it('throws MEMBERSHIP_PROVIDER_NOT_CONFIGURED when dodoApiKey is empty', async () => { + configsService.get.mockResolvedValue({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', + dodoApiKey: '', + dodoWebhookKey: 'test-dodo-webhook-key', + dodoEnvironment: 'test_mode', + }) + + const provider = new DodoProvider(configsService as any) + + await expect( + provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED, + }) + }) + + it('rebuilds the client when the configured api key or environment changes', async () => { + const DodoPayments = (await import('dodopayments')).default as any + + checkoutCreateMock.mockResolvedValue({ + session_id: 'sess_1', + checkout_url: 'https://checkout.dodopayments.com/sess_1', + }) + + const provider = new DodoProvider(configsService as any) + await provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + }) + const firstClient = (provider as any).client + + configsService.get.mockResolvedValue({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', + dodoApiKey: 'rotated-dodo-api-key', + dodoWebhookKey: 'test-dodo-webhook-key', + dodoEnvironment: 'live_mode', + }) + await provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + }) + const secondClient = (provider as any).client + + expect(secondClient).not.toBe(firstClient) + expect(secondClient).toBeInstanceOf(DodoPayments) + }) }) describe('verifyAndParseWebhook', () => { @@ -142,6 +202,26 @@ describe('DodoProvider', () => { expect(event.plan).toBe('yearly') }) + it('throws MEMBERSHIP_PROVIDER_NOT_CONFIGURED when dodoWebhookKey is empty', async () => { + configsService.get.mockResolvedValue({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', + dodoApiKey: 'test-dodo-api-key', + dodoWebhookKey: '', + dodoEnvironment: 'test_mode', + }) + + const provider = new DodoProvider(configsService as any) + + await expect( + provider.verifyAndParseWebhook('{}', headers), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED, + }) + }) + it('throws WebhookVerifyFailed when signature verification fails', async () => { verifyMock.mockImplementation(() => { throw new Error('invalid signature') diff --git a/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md b/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md index 23071de2ac6..733c77a0168 100644 --- a/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md +++ b/docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md @@ -128,10 +128,20 @@ The paywall touches many surfaces; coupling is contained by four rules: ## Configuration -- Secrets via environment / `app.config.ts`: `DODO_API_KEY`, - `DODO_WEBHOOK_KEY` (per-provider naming as adapters are added). -- Configs module (admin-editable) gains a `membership` section: `enabled`, - `provider` (single choice), `monthlyProductId`, `yearlyProductId`. +- Configs module (admin-editable, `apps/core/src/modules/configs/`) gains a + `membership` section: `enabled`, `provider` (single choice), + `monthlyProductId`, `yearlyProductId`, `dodoApiKey`, `dodoWebhookKey`, + `dodoEnvironment` (`test_mode` | `live_mode`, defaults to `live_mode`). + There are no `DODO_*` environment variables — all Dodo settings live in + this section and are editable at runtime from the admin settings UI. + `dodoApiKey` and `dodoWebhookKey` use the same `field.password` helper as + other secret fields (SMTP password, AI provider API keys): encrypted at + rest, masked to empty string on read, and rendered as password inputs in + the schema-driven admin settings form. +- `DodoProvider` reads the section via `ConfigsService.get('membership')` on + every call and rebuilds its cached `DodoPayments` client whenever the API + key or environment differs from what it last built with, so changes made + in the admin UI take effect without a restart. ## API (new `membership` module) From 0508c9fbfffad2fdd57b8360384e50bb45fc1990 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 22:49:31 +0800 Subject: [PATCH 19/37] fix(admin): improve webhook editor feedback --- .../webhooks/components/WebhookDetail.tsx | 2 +- .../components/WebhookEditorModal.tsx | 172 +++++++++++++----- apps/admin/src/i18n/resources/en-US.ts | 11 ++ apps/admin/src/i18n/resources/zh-CN.ts | 8 + .../ui/data/responsive-data-table.test.tsx | 145 +++++++-------- .../stock/__tests__/StockNode.test.ts | 53 +++++- 6 files changed, 265 insertions(+), 126 deletions(-) diff --git a/apps/admin/src/features/webhooks/components/WebhookDetail.tsx b/apps/admin/src/features/webhooks/components/WebhookDetail.tsx index 7234282b1aa..99edaf6a49a 100644 --- a/apps/admin/src/features/webhooks/components/WebhookDetail.tsx +++ b/apps/admin/src/features/webhooks/components/WebhookDetail.tsx @@ -102,7 +102,7 @@ export function WebhookDetail(props: { className="size-7 text-neutral-500 dark:text-neutral-400" /> -
+
diff --git a/apps/admin/src/features/webhooks/components/WebhookEditorModal.tsx b/apps/admin/src/features/webhooks/components/WebhookEditorModal.tsx index 7488df957a8..81fc05bd9c0 100644 --- a/apps/admin/src/features/webhooks/components/WebhookEditorModal.tsx +++ b/apps/admin/src/features/webhooks/components/WebhookEditorModal.tsx @@ -1,4 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query' +import { CircleAlert, Loader2 } from 'lucide-react' import type { FormEvent } from 'react' import { useState } from 'react' import { toast } from 'sonner' @@ -19,6 +20,7 @@ import { Checkbox } from '~/ui/primitives/checkbox' import { Scroll } from '~/ui/primitives/scroll' import { Switch } from '~/ui/primitives/switch' import { TextInput } from '~/ui/primitives/text-field' +import { cn } from '~/utils/cn' import { scopeOptions } from '../constants' @@ -48,6 +50,11 @@ function WebhookEditorModal(props: WebhookEditorModalProps) { }) const availableEvents = eventsQuery.data ?? [] const allEventsChecked = events.includes('all') + const selectedEventCount = allEventsChecked + ? availableEvents.length + : events.filter((event) => availableEvents.includes(event)).length + const someEventsChecked = !allEventsChecked && selectedEventCount > 0 + const clearError = () => setError('') const mutation = useMutation({ mutationFn: async () => { @@ -62,6 +69,13 @@ function WebhookEditorModal(props: WebhookEditorModalProps) { if (props.webhook?.id) return updateWebhook(props.webhook.id, data) return createWebhook({ ...data, secret: secret.trim() || '' }) }, + onError: (mutationError: unknown) => { + setError( + mutationError instanceof Error && mutationError.message + ? mutationError.message + : t('webhooks.editor.saveFailed'), + ) + }, onSuccess: (webhook) => { toast.success( isEdit ? t('webhooks.toast.updated') : t('webhooks.toast.created'), @@ -69,6 +83,9 @@ function WebhookEditorModal(props: WebhookEditorModalProps) { modal.close(webhook) }, }) + const submitLabel = isEdit + ? t(mutation.isPending ? 'webhooks.editor.saving' : 'common.save') + : t(mutation.isPending ? 'webhooks.editor.creating' : 'common.create') const handleSubmit = (event?: FormEvent) => { event?.preventDefault() @@ -88,7 +105,11 @@ function WebhookEditorModal(props: WebhookEditorModalProps) { } return ( -
+ - + { + clearError() + setPayloadUrl(value) + }} placeholder="https://example.com/webhook" required value={payloadUrl} /> { + clearError() + setSecret(value) + }} placeholder={ isEdit ? t('webhooks.editor.placeholder.secret.edit') @@ -117,61 +144,101 @@ function WebhookEditorModal(props: WebhookEditorModalProps) { value={secret} /> -
- +
+ {t('webhooks.editor.events')}{' '} * -
-
- +
+ {t('webhooks.editor.scope')} -
+

+ {t('webhooks.editor.scopeDescription')} +

+
{scopeOptions.map((option) => ( @@ -180,20 +247,39 @@ function WebhookEditorModal(props: WebhookEditorModalProps) {
{ + clearError() + setEnabled(checked) + }} /> - - {error ? {error} : null} + {error ? ( +
+
+ ) : null} + @@ -211,8 +297,8 @@ export async function presentWebhookEditor( { webhook }, { modalProps: { - className: 'max-h-[90vh]', - popupStyle: { width: 'min(92vw, 42rem)' }, + className: 'max-h-[90svh]', + popupStyle: { width: 'min(94vw, 46rem)' }, }, }, ) diff --git a/apps/admin/src/i18n/resources/en-US.ts b/apps/admin/src/i18n/resources/en-US.ts index 0ac83f6b9bf..ef704ccc8f3 100644 --- a/apps/admin/src/i18n/resources/en-US.ts +++ b/apps/admin/src/i18n/resources/en-US.ts @@ -2669,13 +2669,24 @@ export const enUS = { 'webhooks.dispatch.title': 'Dispatch records', 'webhooks.dispatch.totalSuffix': '{count} total', 'webhooks.editor.allEvents': 'All events', + 'webhooks.editor.creating': 'Creating...', 'webhooks.editor.createTitle': 'Create webhook', 'webhooks.editor.editTitle': 'Edit webhook', 'webhooks.editor.enabled': 'Enabled state', + 'webhooks.editor.enabledDescription': + 'Keep this configuration, but stop dispatching events when disabled', 'webhooks.editor.events': 'Trigger events', + 'webhooks.editor.eventsDescription': + 'Select the business events sent to this endpoint', + 'webhooks.editor.eventsLoadFailed': 'Unable to load the event list', 'webhooks.editor.placeholder.secret.create': 'Optional signing secret', 'webhooks.editor.placeholder.secret.edit': 'Leave empty to keep unchanged', + 'webhooks.editor.saveFailed': 'Failed to save webhook', + 'webhooks.editor.saving': 'Saving...', + 'webhooks.editor.selectedEvents': '{selected}/{total} selected', 'webhooks.editor.scope': 'Trigger scope', + 'webhooks.editor.scopeDescription': + 'Only dispatch events originating from the selected sources', 'webhooks.editor.validate.eventRequired': 'Select at least one trigger event', 'webhooks.editor.validate.urlRequired': 'Payload URL is required', 'webhooks.empty.create': 'Create webhook', diff --git a/apps/admin/src/i18n/resources/zh-CN.ts b/apps/admin/src/i18n/resources/zh-CN.ts index c616df7b5e9..40bbecb77c6 100644 --- a/apps/admin/src/i18n/resources/zh-CN.ts +++ b/apps/admin/src/i18n/resources/zh-CN.ts @@ -2546,13 +2546,21 @@ export const zhCN = { 'webhooks.dispatch.title': '推送记录', 'webhooks.dispatch.totalSuffix': '共 {count} 条', 'webhooks.editor.allEvents': '全部事件', + 'webhooks.editor.creating': '创建中...', 'webhooks.editor.createTitle': '创建 Webhook', 'webhooks.editor.editTitle': '编辑 Webhook', 'webhooks.editor.enabled': '启用状态', + 'webhooks.editor.enabledDescription': '关闭后保留配置,但停止派发事件', 'webhooks.editor.events': '触发事件', + 'webhooks.editor.eventsDescription': '选择需要推送至此端点的业务事件', + 'webhooks.editor.eventsLoadFailed': '无法加载事件列表', 'webhooks.editor.placeholder.secret.create': '可选的签名密钥', 'webhooks.editor.placeholder.secret.edit': '留空保持不变', + 'webhooks.editor.saveFailed': '保存 Webhook 失败', + 'webhooks.editor.saving': '保存中...', + 'webhooks.editor.selectedEvents': '已选择 {selected}/{total}', 'webhooks.editor.scope': '触发范围', + 'webhooks.editor.scopeDescription': '仅派发来自所选来源的事件', 'webhooks.editor.validate.eventRequired': '至少选择一个触发事件', 'webhooks.editor.validate.urlRequired': 'Payload URL 不可为空', 'webhooks.empty.create': '创建 Webhook', diff --git a/apps/admin/src/ui/data/responsive-data-table.test.tsx b/apps/admin/src/ui/data/responsive-data-table.test.tsx index 5e00c0bef13..e29e61b99e3 100644 --- a/apps/admin/src/ui/data/responsive-data-table.test.tsx +++ b/apps/admin/src/ui/data/responsive-data-table.test.tsx @@ -1,9 +1,10 @@ -import { act, createElement } from 'react' +import { act, createElement, type ReactNode } from 'react' +import type { Root } from 'react-dom/client' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ResponsiveDataTableColumn } from '~/ui/data/responsive-data-table' -import type { Root } from 'react-dom/client' +import { I18nProvider } from '~/i18n' +import type { ResponsiveDataTableColumn } from '~/ui/data/responsive-data-table' import { DefaultRowCard, ResponsiveDataTable, @@ -48,6 +49,12 @@ function setMatchMedia(matches: boolean) { }) } +function render(element: ReactNode) { + act(() => { + harness.root.render(createElement(I18nProvider, null, element)) + }) +} + interface Person { id: string name: string @@ -80,15 +87,13 @@ afterEach(() => { describe('ResponsiveDataTable', () => { it('renders a table with headers on desktop', () => { setMatchMedia(true) - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: people, - columns, - rowKey: (row) => row.id, - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: people, + columns, + rowKey: (row) => row.id, + }), + ) const table = harness.container.querySelector('table') expect(table).not.toBeNull() const headers = Array.from(table!.querySelectorAll('th')).map( @@ -101,15 +106,13 @@ describe('ResponsiveDataTable', () => { it('renders a stacked card list on mobile with a title per row', () => { setMatchMedia(false) - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: people, - columns, - rowKey: (row) => row.id, - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: people, + columns, + rowKey: (row) => row.id, + }), + ) expect(harness.container.querySelector('table')).toBeNull() const titles = Array.from( harness.container.querySelectorAll('div.text-sm.font-medium'), @@ -126,16 +129,14 @@ describe('ResponsiveDataTable', () => { `custom-${row.id}`, ), ) - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: people, - columns, - rowKey: (row) => row.id, - mobileCard, - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: people, + columns, + rowKey: (row) => row.id, + mobileCard, + }), + ) expect(mobileCard).toHaveBeenCalledTimes(2) const cards = Array.from( harness.container.querySelectorAll('[data-testid="custom-card"]'), @@ -156,15 +157,13 @@ describe('ResponsiveDataTable', () => { }, { key: 'role', header: 'Role', render: (row) => row.role }, ] - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: people, - columns: hiddenColumns, - rowKey: (row) => row.id, - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: people, + columns: hiddenColumns, + rowKey: (row) => row.id, + }), + ) expect(harness.container.textContent).toContain('Alice') expect(harness.container.textContent).toContain('admin') expect(harness.container.textContent).not.toContain('a@example.com') @@ -173,32 +172,28 @@ describe('ResponsiveDataTable', () => { it('renders empty fallback on both breakpoints', () => { setMatchMedia(true) - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: [], - columns, - rowKey: (row) => row.id, - empty: createElement('div', { 'data-testid': 'empty' }, 'nothing'), - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: [], + columns, + rowKey: (row) => row.id, + empty: createElement('div', { 'data-testid': 'empty' }, 'nothing'), + }), + ) expect( harness.container.querySelector('[data-testid="empty"]'), ).not.toBeNull() expect(harness.container.querySelector('table')).toBeNull() setMatchMedia(false) - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: [], - columns, - rowKey: (row) => row.id, - empty: createElement('div', { 'data-testid': 'empty' }, 'nothing'), - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: [], + columns, + rowKey: (row) => row.id, + empty: createElement('div', { 'data-testid': 'empty' }, 'nothing'), + }), + ) expect( harness.container.querySelector('[data-testid="empty"]'), ).not.toBeNull() @@ -208,15 +203,13 @@ describe('ResponsiveDataTable', () => { setMatchMedia(false) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - act(() => { - harness.root.render( - createElement(ResponsiveDataTable, { - rows: people, - columns, - rowKey: (row) => row.id, - }), - ) - }) + render( + createElement(ResponsiveDataTable, { + rows: people, + columns, + rowKey: (row) => row.id, + }), + ) const keyWarnings = errorSpy.mock.calls.filter((call) => { const msg = String(call[0] ?? '') return msg.includes('unique "key" prop') @@ -231,14 +224,12 @@ describe('ResponsiveDataTable', () => { describe('DefaultRowCard', () => { it('renders first column as title and remaining as label/value grid', () => { setMatchMedia(false) - act(() => { - harness.root.render( - createElement(DefaultRowCard, { - row: people[0], - columns, - }), - ) - }) + render( + createElement(DefaultRowCard, { + row: people[0], + columns, + }), + ) const title = harness.container.querySelector('div.text-sm.font-medium') expect(title?.textContent).toBe('Alice') const dts = Array.from(harness.container.querySelectorAll('dt')).map( diff --git a/apps/admin/src/vendor/rich-editor/extensions/stock/__tests__/StockNode.test.ts b/apps/admin/src/vendor/rich-editor/extensions/stock/__tests__/StockNode.test.ts index 44d8150e3b9..ab03b1908a3 100644 --- a/apps/admin/src/vendor/rich-editor/extensions/stock/__tests__/StockNode.test.ts +++ b/apps/admin/src/vendor/rich-editor/extensions/stock/__tests__/StockNode.test.ts @@ -1,8 +1,13 @@ -import { createEditor } from 'lexical' +import { $createParagraphNode, $getRoot, createEditor } from 'lexical' import { describe, expect, it, vi } from 'vitest' +import { + registerStockDialogOpener, + unregisterStockDialogOpener, +} from '../stock-plugin-bridge' import { $createStockNode, + $isStockNode, type SerializedStockNode, StockNode, type StockNodePayload, @@ -73,9 +78,47 @@ describe('StockNode', () => { expect(restoredPayload).toEqual(original) }) - it('exposes exactly two commandItems for snapshot and kline', () => { - expect(StockNode.commandItems.length).toBe(2) - expect(StockNode.commandItems[0]!.title).toContain('snapshot') - expect(StockNode.commandItems[1]!.title).toContain('K-line') + it('opens the stock dialog and inserts the submitted stock selection', async () => { + const editor = createEditor({ nodes: [StockNode], onError: () => {} }) + editor.update( + () => { + const paragraph = $createParagraphNode() + $getRoot().append(paragraph) + paragraph.select() + }, + { discrete: true }, + ) + + const openDialog = vi.fn() + registerStockDialogOpener(editor, openDialog) + + const commandItem = StockNode.commandItems.find((item) => + item.keywords?.includes('stock'), + ) + expect(commandItem).toBeDefined() + + commandItem!.onSelect(editor, '') + expect(openDialog).toHaveBeenCalledOnce() + + const payload: StockNodePayload = { + variant: 'snapshot', + symbol: 'MSFT', + } + const update = new Promise((resolve) => { + const unregister = editor.registerUpdateListener(() => { + unregister() + resolve() + }) + }) + openDialog.mock.calls[0]![0].onSubmit(payload) + await update + + const insertedPayload = editor.getEditorState().read(() => { + const stockNode = $getRoot().getChildren().find($isStockNode) + return stockNode?.getPayload() + }) + expect(insertedPayload).toEqual(payload) + + unregisterStockDialogOpener(editor) }) }) From 7bf28007dadf7da56df6b2362fd1bc01a93667f3 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 22:52:29 +0800 Subject: [PATCH 20/37] feat(configs): surface membership section in admin settings groups --- apps/core/src/modules/configs/configs.dsl.util.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/core/src/modules/configs/configs.dsl.util.ts b/apps/core/src/modules/configs/configs.dsl.util.ts index ffb9374b49a..dffb6c35514 100644 --- a/apps/core/src/modules/configs/configs.dsl.util.ts +++ b/apps/core/src/modules/configs/configs.dsl.util.ts @@ -139,6 +139,13 @@ const groupConfigs: GroupConfig[] = [ icon: 'settings', sectionKeys: ['adminExtra', 'featureList'], }, + { + key: 'membership', + title: 'Membership', + description: 'Paid membership, payment provider', + icon: 'credit-card', + sectionKeys: ['membership'], + }, ] // ==================== Type Inference Utilities ==================== From 6bca55e24a94caba210652d9153855dedb793b3b Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 23:10:58 +0800 Subject: [PATCH 21/37] fix(aggregate): gate premium post body behind paywall in /aggregate/latest getLatest() returned posts verbatim, so the public timeline leaked a premium post's full text/lexical content in both the combined and {posts, notes} response shapes. Map posts through getPublicText/ getPublicContent so premium posts surface only the teaser. --- .../modules/aggregate/aggregate.service.ts | 13 ++- .../aggregate/aggregate.service.spec.ts | 84 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/core/src/modules/aggregate/aggregate.service.ts b/apps/core/src/modules/aggregate/aggregate.service.ts index 72d4785ad44..35cc63d287d 100644 --- a/apps/core/src/modules/aggregate/aggregate.service.ts +++ b/apps/core/src/modules/aggregate/aggregate.service.ts @@ -114,9 +114,14 @@ export class AggregateService { ? this.noteService.findRecent(limit, { visibleOnly: true }) : undefined, ]) + const publicPosts = posts?.map((post) => ({ + ...post, + text: getPublicText(post), + content: getPublicContent(post), + })) if (combined) { return [ - ...(posts ?? []).map((item) => ({ ...item, type: 'post' })), + ...(publicPosts ?? []).map((item) => ({ ...item, type: 'post' })), ...(notes ?? []).map((item) => ({ ...item, type: 'note' })), ] .sort( @@ -126,7 +131,7 @@ export class AggregateService { .slice(0, limit) } return { - ...(posts ? { posts } : {}), + ...(publicPosts ? { posts: publicPosts } : {}), ...(notes ? { notes } : {}), } } @@ -217,11 +222,11 @@ export class AggregateService { modified: item.modifiedAt ?? null, link: baseURL + this.urlBuilder.build(item as any), title: item.title ?? '', - text: getPublicText(item as any), + text: item.text ?? '', id: item.id, images: item.images ?? [], contentFormat: item.contentFormat, - content: getPublicContent(item as any) ?? undefined, + content: item.content ?? undefined, })), } } diff --git a/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts b/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts index 7562ce00330..7d770e29055 100644 --- a/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts +++ b/apps/core/test/src/modules/aggregate/aggregate.service.spec.ts @@ -90,6 +90,90 @@ describe('AggregateService.topActivity', () => { }) }) +describe('AggregateService.getLatest', () => { + const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }) + const premiumPost = { + ...postRow, + isPremium: true, + text: 'full premium body', + content: premiumContent, + } + + const createServiceWithPremiumPost = () => { + const postService = { findRecent: vi.fn(async () => [premiumPost]) } + const noteService = { findRecent: vi.fn(async () => [noteRow]) } + + const service = new AggregateService( + postService as any, + noteService as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ) + return { service } + } + + it('teasers a premium post in the { posts, notes } branch', async () => { + const { service } = createServiceWithPremiumPost() + const result = await service.getLatest(5) + + expect(result.posts![0].text).not.toContain('full premium body') + expect(JSON.parse(result.posts![0].content!).root.children).toHaveLength(2) + }) + + it('teasers a premium post in the combined branch', async () => { + const { service } = createServiceWithPremiumPost() + const result = await service.getLatest(5, undefined, true) + + const post = result.find((item: any) => item.type === 'post')! + expect((post as any).text).not.toContain('full premium body') + expect(JSON.parse((post as any).content).root.children).toHaveLength(2) + }) +}) + describe('AggregateService.buildRssStructure', () => { const premiumContent = JSON.stringify({ root: { From c89878fc0c18731fb4454ca29c73477549feb260 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 23:11:09 +0800 Subject: [PATCH 22/37] fix(activity): stop leaking draft and premium post bodies from /activity getRecentPublish() and getLastYearPublication() fetched posts without publishedOnly, exposing unpublished drafts, and returned bodies verbatim, exposing full premium content on public endpoints. Restrict both to published posts and teaser premium ones via getPublicText/getPublicContent. --- .../src/modules/activity/activity.service.ts | 36 ++++--- .../modules/activity/activity.service.spec.ts | 101 ++++++++++++++++++ 2 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 apps/core/test/src/modules/activity/activity.service.spec.ts diff --git a/apps/core/src/modules/activity/activity.service.ts b/apps/core/src/modules/activity/activity.service.ts index 11ba816c4ac..e436e413732 100644 --- a/apps/core/src/modules/activity/activity.service.ts +++ b/apps/core/src/modules/activity/activity.service.ts @@ -13,6 +13,10 @@ import { GatewayService } from '~/processors/gateway/gateway.service' import { WebEventsGateway } from '~/processors/gateway/web/events.gateway' import { CountingService } from '~/processors/helper/helper.counting.service' import { EventManagerService } from '~/processors/helper/helper.event.service' +import { + getPublicContent, + getPublicText, +} from '~/processors/helper/lexical-truncate.util' import { checkRefModelCollectionType } from '~/utils/biz.util' import { camelcaseKeys } from '~/utils/tool.util' @@ -169,8 +173,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const readerIds: string[] = [] for (const item of activities.data) { const payload = toObjectPayload(item.payload) as - | Partial - | undefined + Partial | undefined if (!payload) continue const { type, id, readerId } = payload if (typeof type === 'string' && typeof id === 'string') { @@ -199,8 +202,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const docsWithRefModel = activities.data.map((ac) => { const nextAc = { ...ac } as ActivityWithRef const payload = toObjectPayload(ac.payload) as - | ActivityPayloadWithRef - | undefined + ActivityPayloadWithRef | undefined if (!payload) return nextAc const refModel = payload.id ? refModelData.get(payload.id) : undefined @@ -230,8 +232,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const articleIds: string[] = [] for (const item of data.data) { const payload = toObjectPayload(item.payload) as - | ActivityPayloadWithRef - | undefined + ActivityPayloadWithRef | undefined if (!payload || typeof payload.roomName !== 'string') continue const refId = extractArticleIdFromRoomName(payload.roomName) articleIds.push(refId) @@ -444,8 +445,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const countMap = new Map() for (const item of activities) { const payload = toObjectPayload(item.payload) as - | ActivityPayloadWithRef - | undefined + ActivityPayloadWithRef | undefined if (!payload || typeof payload.roomName !== 'string') continue const refId = extractArticleIdFromRoomName(payload.roomName) if (!refId) continue @@ -502,14 +502,18 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { } async getRecentPublish() { - const [post, note] = await Promise.all([ - this.postService.findRecent(3), + const [posts, note] = await Promise.all([ + this.postService.findRecent(3, { publishedOnly: true }), this.noteService.findRecent(3, { visibleOnly: true }), ]) return { recent: [], - post, + post: posts.map((post) => ({ + ...post, + text: getPublicText(post), + content: getPublicContent(post), + })), note, } } @@ -520,10 +524,16 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { async getLastYearPublication() { const $gte = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) const [allPosts, allNotes] = await Promise.all([ - this.postService.findRecent(50), + this.postService.findRecent(50, { publishedOnly: true }), this.noteService.findRecent(50), ]) - const posts = allPosts.filter((row) => row.createdAt >= $gte) + const posts = allPosts + .filter((row) => row.createdAt >= $gte) + .map((post) => ({ + ...post, + text: getPublicText(post), + content: getPublicContent(post), + })) const notes = allNotes .filter((row) => row.createdAt >= $gte) .map((note) => { diff --git a/apps/core/test/src/modules/activity/activity.service.spec.ts b/apps/core/test/src/modules/activity/activity.service.spec.ts new file mode 100644 index 00000000000..2a1e44f322b --- /dev/null +++ b/apps/core/test/src/modules/activity/activity.service.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' + +import { ActivityService } from '~/modules/activity/activity.service' + +const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + { + type: 'paragraph', + children: [], + direction: null, + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, +}) + +const premiumPost = { + id: '7000000000000000060', + title: 'A Premium Post', + slug: 'a-premium-post', + isPremium: true, + text: 'full premium body', + content: premiumContent, + createdAt: new Date(), +} + +const createService = (postFindRecent: ReturnType) => { + const noteService = { findRecent: vi.fn(async () => []) } + return new ActivityService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { findRecent: postFindRecent } as any, + noteService as any, + {} as any, + ) +} + +describe('ActivityService.getRecentPublish', () => { + it('requests only published posts and teasers a premium post', async () => { + const findRecent = vi.fn(async () => [premiumPost]) + const service = createService(findRecent) + + const result = await service.getRecentPublish() + + expect(findRecent).toHaveBeenCalledWith(3, { publishedOnly: true }) + expect(result.post[0].text).not.toContain('full premium body') + expect(JSON.parse(result.post[0].content).root.children).toHaveLength(2) + }) +}) + +describe('ActivityService.getLastYearPublication', () => { + it('requests only published posts and teasers a premium post', async () => { + const findRecent = vi.fn(async () => [premiumPost]) + const service = createService(findRecent) + + const result = await service.getLastYearPublication() + + expect(findRecent).toHaveBeenCalledWith(50, { publishedOnly: true }) + expect(result.posts[0].text).not.toContain('full premium body') + expect(JSON.parse(result.posts[0].content).root.children).toHaveLength(2) + }) + + it('asks the post repository to exclude unpublished drafts', async () => { + const findRecent = vi.fn(async () => []) + const service = createService(findRecent) + + await service.getLastYearPublication() + + expect(findRecent).toHaveBeenCalledWith(50, { publishedOnly: true }) + }) +}) From 2f4f36996c0e812e7d9fd7b40193a8bb864edfbe Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 23:11:17 +0800 Subject: [PATCH 23/37] fix(ai-translation): block public translation endpoints for premium posts GET /ai/translations/article/:id and its /generate SSE counterpart are unauthenticated and streamed a premium post's full translated body, bypassing the paywall entirely. Add a blockPremium option to loadVisibleArticleOrThrow, scoped to the two public read paths (getTranslationForArticle, streamTranslationForArticle) only; the owner-authenticated getTranslationAndAvailableLanguages path (used by post/note/page detail routes via helper.translation.service, where paywall gating is applied separately) is untouched. --- .../ai-translation/ai-translation.service.ts | 22 ++++++++-- .../modules/ai/ai-translation.service.spec.ts | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts b/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts index 5dd1a8a41ac..1ee92a3bc01 100644 --- a/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts +++ b/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts @@ -882,8 +882,10 @@ export class AiTranslationService // Gate the public stream path on visibility, matching the read path // (`getTranslationForArticle`) so drafts / protected articles never leak. - const { article, document } = - await this.loadVisibleArticleOrThrow(articleId) + const { article, document } = await this.loadVisibleArticleOrThrow( + articleId, + { blockPremium: true }, + ) const { type } = article // Check whether a valid translation (matching hash) already exists in the database. @@ -1113,7 +1115,7 @@ export class AiTranslationService private async loadVisibleArticleOrThrow( articleId: string, - options?: { ignoreVisibility?: boolean }, + options?: { ignoreVisibility?: boolean; blockPremium?: boolean }, ) { const article = await this.databaseService.findGlobalById(articleId) if (!article || !article.document) { @@ -1129,6 +1131,15 @@ export class AiTranslationService throw createAppException(AppErrorCode.NOTE_FORBIDDEN) } + if ( + options?.blockPremium && + article.type === CollectionRefTypes.Post && + (article.document as ArticleDocument as { isPremium?: boolean | null }) + .isPremium + ) { + throw createAppException(AppErrorCode.POST_HIDDEN_OR_ENCRYPTED) + } + return { article, document: article.document as ArticleDocument } } @@ -1138,7 +1149,10 @@ export class AiTranslationService options?: { ignoreVisibility?: boolean }, ): Promise { const [{ document }, translation] = await Promise.all([ - this.loadVisibleArticleOrThrow(articleId, options), + this.loadVisibleArticleOrThrow(articleId, { + ...options, + blockPremium: true, + }), this.aiTranslationRepository.findByRefAndLang(articleId, targetLang), ]) diff --git a/apps/core/test/src/modules/ai/ai-translation.service.spec.ts b/apps/core/test/src/modules/ai/ai-translation.service.spec.ts index 2f350fda3a9..9cd856d5ff3 100644 --- a/apps/core/test/src/modules/ai/ai-translation.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-translation.service.spec.ts @@ -271,6 +271,46 @@ describe('AiTranslationService', () => { }) }) + it('blocks the public article-translation read for a premium post', async () => { + const { databaseService, service } = createService() + databaseService.findGlobalById.mockResolvedValue({ + document: articleDocument({ isPremium: true }), + type: CollectionRefTypes.Post, + }) + + await expect( + service.getTranslationForArticle('post-1', 'en'), + ).rejects.toThrow(AppException) + }) + + it('does not block the owner-authenticated translation lookup for a premium post', async () => { + const { databaseService, repository, service } = createService() + const validTranslation = row({ lang: 'en' }) + databaseService.findGlobalById.mockResolvedValue({ + document: articleDocument({ isPremium: true }), + type: CollectionRefTypes.Post, + }) + repository.listByRefId.mockResolvedValue([validTranslation]) + + await expect( + service.getTranslationAndAvailableLanguages('post-1', 'en', { + ignoreVisibility: true, + }), + ).resolves.toMatchObject({ translation: validTranslation }) + }) + + it('blocks the public streamed translation for a premium post', async () => { + const { databaseService, service } = createService() + databaseService.findGlobalById.mockResolvedValue({ + document: articleDocument({ isPremium: true }), + type: CollectionRefTypes.Post, + }) + + await expect( + service.streamTranslationForArticle('post-1', 'en'), + ).rejects.toThrow(AppException) + }) + it('throws when deleting a missing translation row', async () => { const { repository, service } = createService() repository.deleteById.mockResolvedValue(0) From a4bd082cb12c78acdc0db5fad44dd0be9b8172fc Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 23:22:11 +0800 Subject: [PATCH 24/37] fix(ai-insights): block public insights endpoints for premium posts GET /ai/insights/article/:id and its /generate SSE counterpart are unauthenticated and served a premium post's AI insights, bypassing the paywall. Add a blockPremium option to resolveArticleForInsights, scoped to the two public read paths (getOrGenerateInsightsForArticle, streamInsightsForArticle) only; @Auth-gated routes and background task/event-driven generation are untouched. --- .../ai/ai-insights/ai-insights.service.ts | 22 ++++-- .../modules/ai/ai-insights.service.spec.ts | 78 ++++++++++++++++++- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts b/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts index 0a795065134..e822d4b7a84 100644 --- a/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts +++ b/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts @@ -121,7 +121,10 @@ export class AiInsightsService implements OnModuleInit { ) } - private async resolveArticleForInsights(articleId: string): Promise<{ + private async resolveArticleForInsights( + articleId: string, + options?: { blockPremium?: boolean }, + ): Promise<{ article: ArticleForInsights type: CollectionRefTypes.Post | CollectionRefTypes.Note }> { @@ -135,11 +138,16 @@ export class AiInsightsService implements OnModuleInit { ) { throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } - // Never expose insights for draft / password-protected / future-dated - // content. Public endpoints and background tasks both flow through here. if (!isGlobalArticleVisible(article)) { throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } + if ( + options?.blockPremium && + article.type === CollectionRefTypes.Post && + (article.document as { isPremium?: boolean | null }).isPremium + ) { + throw createAppException(AppErrorCode.POST_HIDDEN_OR_ENCRYPTED) + } const doc = article.document as any return { article: { @@ -361,7 +369,9 @@ export class AiInsightsService implements OnModuleInit { if (!aiConfig?.enableInsights) { throw createAppException(AppErrorCode.AI_NOT_ENABLED) } - const { article } = await this.resolveArticleForInsights(articleId) + const { article } = await this.resolveArticleForInsights(articleId, { + blockPremium: true, + }) const lang = options.lang || this.resolveSourceLang(article) const existing = await this.findValidInsights(articleId, lang, article.text) if (existing) { @@ -375,7 +385,9 @@ export class AiInsightsService implements OnModuleInit { articleId: string, options: { lang: string; onlyDb?: boolean }, ): Promise { - const { article } = await this.resolveArticleForInsights(articleId) + const { article } = await this.resolveArticleForInsights(articleId, { + blockPremium: true, + }) const lang = options.lang || this.resolveSourceLang(article) const existing = await this.findValidInsights(articleId, lang, article.text) if (existing) return existing diff --git a/apps/core/test/src/modules/ai/ai-insights.service.spec.ts b/apps/core/test/src/modules/ai/ai-insights.service.spec.ts index 5934cfd3541..dd34ef4155c 100644 --- a/apps/core/test/src/modules/ai/ai-insights.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-insights.service.spec.ts @@ -160,9 +160,83 @@ describe('AiInsightsService', () => { ]) }) + it('blocks the public article-insights read for a premium post', async () => { + const { databaseService, service } = createService() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: 'post-1', + title: 'Premium Post', + text: 'Premium text', + isPublished: true, + isPremium: true, + }, + }) + + await expect( + service.getOrGenerateInsightsForArticle('post-1', { lang: 'zh' }), + ).rejects.toThrow(AppException) + }) + + it('blocks the public streamed article-insights for a premium post', async () => { + const { configService, databaseService, service } = createService() + configService.get.mockResolvedValue({ enableInsights: true }) + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: 'post-1', + title: 'Premium Post', + text: 'Premium text', + isPublished: true, + isPremium: true, + }, + }) + + await expect( + service.streamInsightsForArticle('post-1', { lang: 'zh' }), + ).rejects.toThrow(AppException) + }) + + it('does not block background insight regeneration for a premium post', async () => { + const { + aiTaskService, + configService, + databaseService, + repository, + service, + } = createService() + configService.get.mockResolvedValue({ + enableInsights: true, + enableAutoGenerateInsightsOnUpdate: true, + insightsMinTextLength: 0, + }) + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: 'post-1', + title: 'Premium Post', + text: 'Long enough premium text', + isPublished: true, + isPremium: true, + }, + }) + repository.findSourceForRef.mockResolvedValue(null) + + await service.handleUpdateArticle({ id: 'post-1' }) + + expect(aiTaskService.createInsightsTask).toHaveBeenCalledWith({ + refId: 'post-1', + }) + }) + it('creates an initial insights task on update when no source insight exists', async () => { - const { aiTaskService, configService, databaseService, repository, service } = - createService() + const { + aiTaskService, + configService, + databaseService, + repository, + service, + } = createService() configService.get.mockResolvedValue({ enableInsights: true, enableAutoGenerateInsightsOnUpdate: true, From becaebb3e5f8dddb6c0babde5f703282b569dc1c Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 23:46:53 +0800 Subject: [PATCH 25/37] fix(search): enforce paywall on public search result bodies loadSearchResultData re-fetched full premium post rows (text/content) for public search callers, bypassing the paywall teaser used elsewhere. Project post rows through the existing toPublicArticleFields() helper unless the caller has admin access. --- .../core/src/modules/search/search.service.ts | 5 +- .../src/modules/search/search.service.spec.ts | 110 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/apps/core/src/modules/search/search.service.ts b/apps/core/src/modules/search/search.service.ts index 643d8829f4e..30a62c3db47 100644 --- a/apps/core/src/modules/search/search.service.ts +++ b/apps/core/src/modules/search/search.service.ts @@ -857,7 +857,10 @@ export class SearchService { const map = new Map() for (const post of posts) { - map.set(`post:${post.id}`, { ...post, type: 'post' as const }) + const publicPost = hasAdminAccess + ? post + : this.toPublicArticleFields(post as any) + map.set(`post:${post.id}`, { ...publicPost, type: 'post' as const }) } for (const note of notes) { map.set(`note:${note.id}`, { ...note, type: 'note' as const }) diff --git a/apps/core/test/src/modules/search/search.service.spec.ts b/apps/core/test/src/modules/search/search.service.spec.ts index a9fe0b9c418..34f19f446fb 100644 --- a/apps/core/test/src/modules/search/search.service.spec.ts +++ b/apps/core/test/src/modules/search/search.service.spec.ts @@ -425,4 +425,114 @@ describe('SearchService', () => { expect(zh?.lang).toBe('zh') expect(zh?.isFallback).toBe(true) }) + + it('teases the full content of a premium post for public search results', async () => { + const premiumContent = JSON.stringify({ + root: { + children: [ + { + type: 'paragraph', + direction: 'ltr', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'text', + text: 'visible teaser', + detail: 0, + format: 0, + mode: 'normal', + style: '', + version: 1, + }, + ], + }, + { + type: 'paragraph', + direction: 'ltr', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'text', + text: 'secret paywalled body', + detail: 0, + format: 0, + mode: 'normal', + style: '', + version: 1, + }, + ], + }, + ], + }, + }) + const premiumHit = { + id: 's3', + refType: 'post', + refId: 'post-premium', + lang: 'en', + sourceHash: '', + title: 'premium hit', + searchText: 'visible teaser', + terms: ['premium'], + titleTermFreq: { premium: 1 }, + bodyTermFreq: {}, + titleLength: 1, + bodyLength: 1, + slug: null, + nid: null, + isPublished: true, + publicAt: null, + hasPassword: false, + createdAt: now, + modifiedAt: null, + } + + const findByTerms = vi.fn().mockResolvedValue([premiumHit]) + const findByKeyword = vi.fn().mockResolvedValue([]) + const findAll = vi.fn().mockResolvedValue([]) + const findCorpusStatsByLang = vi.fn().mockResolvedValue({ + totalDocs: 1, + avgTitleLength: 1, + avgBodyLength: 1, + }) + + const { service, postService } = makeService({ + searchRepoOverrides: { + findByTerms, + findByKeyword, + findAll, + findCorpusStatsByLang, + }, + }) + + postService.findManyByIds.mockResolvedValue([ + { + id: 'post-premium', + title: 'premium hit', + isPublished: true, + isPremium: true, + contentFormat: ContentFormat.Lexical, + text: 'secret paywalled body full text', + content: premiumContent, + meta: null, + }, + ]) + + const result = (await service.search({ + keyword: 'premium', + page: 1, + size: 10, + lang: 'en', + } as any)) as any + + const hit = result.data.find((d: any) => d.id === 'post-premium') + expect(hit).toBeDefined() + expect(hit.text).not.toContain('secret') + expect(hit.content).not.toContain('secret paywalled body') + expect(hit.content).toContain('visible teaser') + }) }) From 5e22a9eeac68272f149965ade203491dd3953920 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 18 Jul 2026 23:47:02 +0800 Subject: [PATCH 26/37] fix(membership): report effective expired status once currentPeriodEnd lapses GET /membership/status returned the raw stored status, so an active/on_hold row past its currentPeriodEnd still showed 'active' even though EntitlementService already denies access. Add effectiveMembershipStatus() and use it in the status endpoint, matching the semantics already used by apps/admin/src/features/readers/utils/membership-status.ts. --- .../membership/membership.controller.ts | 3 +- .../modules/membership/membership.types.ts | 11 ++++++ .../membership.controller.e2e-spec.ts | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 5f97eda47f5..ef94d2552af 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -26,6 +26,7 @@ import type { FastifyBizRequest } from '~/transformers/get-req.transformer' import type { SessionUser } from '../auth/auth.types' import { ConfigsService } from '../configs/configs.service' import { MembershipService } from './membership.service' +import { effectiveMembershipStatus } from './membership.types' import { DodoProvider } from './providers/dodo.provider' import type { PaymentProviderAdapter } from './providers/provider.interface' @@ -97,7 +98,7 @@ export class MembershipController { } return { - status: membership.status, + status: effectiveMembershipStatus(membership), plan: membership.plan, provider: membership.provider, currentPeriodEnd: membership.currentPeriodEnd, diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index f303ee06d03..2ee7afca62a 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -20,6 +20,17 @@ export interface MembershipRow { updatedAt: Date | null } +export function effectiveMembershipStatus( + membership: Pick, +): MembershipStatus { + if (membership.status !== 'active' && membership.status !== 'on_hold') { + return membership.status + } + return membership.currentPeriodEnd.getTime() > Date.now() + ? membership.status + : 'expired' +} + export interface MembershipMemberRow extends MembershipRow { reader: { id: EntityId diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 3a1ddf79be4..23fcf69235a 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -33,6 +33,7 @@ const snowflake = new SnowflakeService() const readerId = snowflake.nextId() const otherReaderId = snowflake.nextId() const liveSubReaderId = snowflake.nextId() +const expiredReaderId = snowflake.nextId() const readerUser = { id: readerId, @@ -41,6 +42,13 @@ const readerUser = { role: 'reader' as const, } +const expiredReaderUser = { + id: expiredReaderId, + email: 'expired-reader@example.com', + name: 'Reader Expired', + role: 'reader' as const, +} + const membershipConfig: { enabled: boolean provider: string | undefined @@ -65,6 +73,9 @@ const authServiceMock = { if (header === 'reader') { return { user: readerUser, session: { token: 'reader-token' } } } + if (header === 'expired-reader') { + return { user: expiredReaderUser, session: { token: 'expired-token' } } + } return null }), } @@ -130,6 +141,7 @@ beforeAll(async () => { { id: readerId, name: 'Reader One', role: 'reader' }, { id: otherReaderId, name: 'Reader Two', role: 'reader' }, { id: liveSubReaderId, name: 'Reader Three', role: 'reader' }, + { id: expiredReaderId, name: 'Reader Expired', role: 'reader' }, ]) }, 120_000) @@ -245,6 +257,30 @@ describe('MembershipController (e2e)', () => { expect(res.statusCode).toBe(401) }) + + it('reports expired for a stored-active membership past its currentPeriodEnd', async () => { + const membershipRepository = proxy.app.get(MembershipRepository) + await membershipRepository.create({ + readerId: expiredReaderId, + provider: 'dodo', + providerCustomerId: 'cus_expired', + providerSubscriptionId: 'sub_expired', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(Date.now() - 24 * 60 * 60 * 1000), + }) + + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/status', + headers: { 'x-test-reader': 'expired-reader' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ + data: { status: 'expired', plan: 'monthly', provider: 'dodo' }, + }) + }) }) describe('POST /membership/webhook/:provider', () => { From 1da31663deda31bc1e9de2ea6c462812725a2bce Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 00:24:08 +0800 Subject: [PATCH 27/37] feat(membership): thread checkout return_url back to the frontend article Accept a validated relative returnPath on POST /membership/checkout, resolve it against the configured webUrl with a membership=success marker, and forward it to the Dodo checkout session as return_url so readers land back on the article after paying. Off-origin, protocol-relative, and absolute paths are rejected to prevent open redirects. --- .../membership/membership.controller.ts | 10 +++- .../modules/membership/membership.types.ts | 23 ++++++++ .../membership/providers/dodo.provider.ts | 2 + .../providers/provider.interface.ts | 1 + .../modules/membership/dodo.provider.spec.ts | 28 ++++++++++ .../membership.controller.e2e-spec.ts | 52 +++++++++++++++++-- .../membership/membership.types.spec.ts | 38 ++++++++++++++ packages/api-client/controllers/membership.ts | 4 +- 8 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 apps/core/test/src/modules/membership/membership.types.spec.ts diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index ef94d2552af..48c4c63b2f2 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -26,12 +26,16 @@ import type { FastifyBizRequest } from '~/transformers/get-req.transformer' import type { SessionUser } from '../auth/auth.types' import { ConfigsService } from '../configs/configs.service' import { MembershipService } from './membership.service' -import { effectiveMembershipStatus } from './membership.types' +import { + effectiveMembershipStatus, + resolveMembershipReturnUrl, +} from './membership.types' import { DodoProvider } from './providers/dodo.provider' import type { PaymentProviderAdapter } from './providers/provider.interface' const CheckoutSchema = z.object({ plan: z.enum(['monthly', 'yearly']), + returnPath: z.string().max(2048).optional(), }) class CheckoutDto extends createZodDto(CheckoutSchema) {} @@ -81,10 +85,14 @@ export class MembershipController { throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) } + const { webUrl } = await this.configsService.get('url') + const returnUrl = resolveMembershipReturnUrl(body.returnPath, webUrl) + const adapter = this.resolveProvider(membershipConfig.provider) return adapter.createCheckout({ reader: { id: user.id, email: user.email, name: user.name }, plan: body.plan, + returnUrl, }) } diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 2ee7afca62a..3d0cf32bed4 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -31,6 +31,29 @@ export function effectiveMembershipStatus( : 'expired' } +export function resolveMembershipReturnUrl( + returnPath: string | undefined, + webUrl: string | undefined, +): string | undefined { + if (!returnPath || !webUrl) return undefined + if (!returnPath.startsWith('/') || returnPath.startsWith('//')) + return undefined + if (returnPath.includes('\\')) return undefined + + let base: URL + try { + base = new URL(webUrl) + } catch { + return undefined + } + + const resolved = new URL(returnPath, base) + if (resolved.origin !== base.origin) return undefined + + resolved.searchParams.set('membership', 'success') + return resolved.toString() +} + export interface MembershipMemberRow extends MembershipRow { reader: { id: EntityId diff --git a/apps/core/src/modules/membership/providers/dodo.provider.ts b/apps/core/src/modules/membership/providers/dodo.provider.ts index cc06bc4e01f..17c49a12990 100644 --- a/apps/core/src/modules/membership/providers/dodo.provider.ts +++ b/apps/core/src/modules/membership/providers/dodo.provider.ts @@ -77,6 +77,7 @@ export class DodoProvider implements PaymentProviderAdapter { async createCheckout(input: { reader: { id: string; email?: string | null; name?: string | null } plan: MembershipPlan + returnUrl?: string }): Promise<{ checkoutUrl: string }> { const membershipConfig = await this.configsService.get('membership') const productId = @@ -103,6 +104,7 @@ export class DodoProvider implements PaymentProviderAdapter { customer: input.reader.email ? { email: input.reader.email, name: input.reader.name ?? undefined } : undefined, + return_url: input.returnUrl, }) if (!session.checkout_url) { diff --git a/apps/core/src/modules/membership/providers/provider.interface.ts b/apps/core/src/modules/membership/providers/provider.interface.ts index c5dd6b56e99..7e2a7125080 100644 --- a/apps/core/src/modules/membership/providers/provider.interface.ts +++ b/apps/core/src/modules/membership/providers/provider.interface.ts @@ -15,6 +15,7 @@ export interface PaymentProviderAdapter { createCheckout: (input: { reader: { id: string; email?: string | null; name?: string | null } plan: MembershipPlan + returnUrl?: string }) => Promise<{ checkoutUrl: string }> verifyAndParseWebhook: ( diff --git a/apps/core/test/src/modules/membership/dodo.provider.spec.ts b/apps/core/test/src/modules/membership/dodo.provider.spec.ts index 30b4777ea1a..d8bd3a81364 100644 --- a/apps/core/test/src/modules/membership/dodo.provider.spec.ts +++ b/apps/core/test/src/modules/membership/dodo.provider.spec.ts @@ -62,6 +62,34 @@ describe('DodoProvider', () => { ) }) + it('forwards returnUrl as return_url and omits it when absent', async () => { + checkoutCreateMock.mockResolvedValue({ + session_id: 'sess_r', + checkout_url: 'https://checkout.dodopayments.com/sess_r', + }) + + const provider = new DodoProvider(configsService as any) + await provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + returnUrl: 'https://blog.example.com/posts/tech/foo?membership=success', + }) + expect(checkoutCreateMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + return_url: + 'https://blog.example.com/posts/tech/foo?membership=success', + }), + ) + + await provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + }) + expect(checkoutCreateMock).toHaveBeenLastCalledWith( + expect.objectContaining({ return_url: undefined }), + ) + }) + it('uses the yearly product id for the yearly plan', async () => { checkoutCreateMock.mockResolvedValue({ session_id: 'sess_2', diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 23fcf69235a..b51900c7463 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -61,10 +61,14 @@ const membershipConfig: { yearlyProductId: 'prod_yearly', } +const urlConfig: { webUrl: string | undefined } = { webUrl: undefined } + const configsServiceMock = { - get: vi.fn(async (key: string) => - key === 'membership' ? membershipConfig : {}, - ), + get: vi.fn(async (key: string) => { + if (key === 'membership') return membershipConfig + if (key === 'url') return urlConfig + return {} + }), } const authServiceMock = { @@ -156,6 +160,7 @@ describe('MembershipController (e2e)', () => { beforeEach(() => { membershipConfig.enabled = true membershipConfig.provider = 'dodo' + urlConfig.webUrl = undefined createCheckoutMock.mockClear() verifyAndParseWebhookMock.mockClear() }) @@ -186,6 +191,47 @@ describe('MembershipController (e2e)', () => { }) }) + it('resolves returnPath against webUrl and forwards it to the adapter', async () => { + urlConfig.webUrl = 'https://blog.example.com' + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'reader', + 'content-type': 'application/json', + }, + payload: { plan: 'monthly', returnPath: '/posts/tech/foo' }, + }) + + expect(res.statusCode).toBe(201) + expect(createCheckoutMock).toHaveBeenCalledWith( + expect.objectContaining({ + returnUrl: + 'https://blog.example.com/posts/tech/foo?membership=success', + }), + ) + }) + + it('drops an off-origin returnPath (open redirect guard)', async () => { + urlConfig.webUrl = 'https://blog.example.com' + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'reader', + 'content-type': 'application/json', + }, + payload: { plan: 'monthly', returnPath: '//evil.com/phish' }, + }) + + expect(res.statusCode).toBe(201) + expect(createCheckoutMock).toHaveBeenCalledWith( + expect.objectContaining({ returnUrl: undefined }), + ) + }) + it('rejects anonymous callers with 401', async () => { const res = await proxy.app.inject({ method: 'POST', diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts new file mode 100644 index 00000000000..38dad366c9d --- /dev/null +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { resolveMembershipReturnUrl } from '~/modules/membership/membership.types' + +describe('resolveMembershipReturnUrl', () => { + const web = 'https://blog.example.com' + + it('joins a relative path onto webUrl and appends the marker', () => { + expect(resolveMembershipReturnUrl('/posts/tech/foo', web)).toBe( + 'https://blog.example.com/posts/tech/foo?membership=success', + ) + }) + + it('preserves existing query and adds the marker', () => { + expect(resolveMembershipReturnUrl('/posts/foo?ref=x', web)).toBe( + 'https://blog.example.com/posts/foo?ref=x&membership=success', + ) + }) + + it('returns undefined when webUrl or returnPath is missing', () => { + expect(resolveMembershipReturnUrl(undefined, web)).toBeUndefined() + expect(resolveMembershipReturnUrl('/posts/foo', undefined)).toBeUndefined() + expect(resolveMembershipReturnUrl('/posts/foo', '')).toBeUndefined() + }) + + it('rejects absolute, protocol-relative, and backslash paths (open redirect guard)', () => { + expect(resolveMembershipReturnUrl('https://evil.com', web)).toBeUndefined() + expect(resolveMembershipReturnUrl('//evil.com', web)).toBeUndefined() + expect(resolveMembershipReturnUrl('/\\evil.com', web)).toBeUndefined() + expect(resolveMembershipReturnUrl('posts/foo', web)).toBeUndefined() + }) + + it('returns undefined when webUrl is not a valid URL', () => { + expect( + resolveMembershipReturnUrl('/posts/foo', 'not-a-url'), + ).toBeUndefined() + }) +}) diff --git a/packages/api-client/controllers/membership.ts b/packages/api-client/controllers/membership.ts index 3e8a63f1532..a11b59761be 100644 --- a/packages/api-client/controllers/membership.ts +++ b/packages/api-client/controllers/membership.ts @@ -31,9 +31,9 @@ export class MembershipController implements IController { return this.client.proxy(this.base) } - checkout(plan: MembershipPlan) { + checkout(plan: MembershipPlan, returnPath?: string) { return this.proxy.checkout.post({ - data: { plan }, + data: { plan, returnPath }, }) } From 322acd5a702dcd9fed2815ef76ffa841bbf41c07 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 00:26:22 +0800 Subject: [PATCH 28/37] chore(release): api-client 5.6.0-preview.0 --- packages/api-client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 26d6748bde7..4deb36906dc 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "5.5.0", + "version": "5.6.0-preview.0", "description": "A api client for mx-space server@next", "type": "module", "engines": { From d4087c5170666de2c3c1eb0ba22fda186f0f8a58 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 01:11:41 +0800 Subject: [PATCH 29/37] feat(api-client): membership.plans() availability + preview.1 --- packages/api-client/controllers/membership.ts | 5 +++++ packages/api-client/models/membership.ts | 5 +++++ packages/api-client/package.json | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/api-client/controllers/membership.ts b/packages/api-client/controllers/membership.ts index a11b59761be..e230899bb65 100644 --- a/packages/api-client/controllers/membership.ts +++ b/packages/api-client/controllers/membership.ts @@ -4,6 +4,7 @@ import type { IRequestHandler } from '~/interfaces/request' import type { MembershipCheckoutResult, MembershipPlan, + MembershipPlansResult, MembershipStatusResult, } from '~/models/membership' import { autoBind } from '~/utils/auto-bind' @@ -40,4 +41,8 @@ export class MembershipController implements IController { status() { return this.proxy.status.get() } + + plans() { + return this.proxy.plans.get() + } } diff --git a/packages/api-client/models/membership.ts b/packages/api-client/models/membership.ts index f396a8595d9..7b7b72179f9 100644 --- a/packages/api-client/models/membership.ts +++ b/packages/api-client/models/membership.ts @@ -9,6 +9,11 @@ export interface MembershipCheckoutResult { checkoutUrl: string } +export interface MembershipPlansResult { + enabled: boolean + plans: MembershipPlan[] +} + export interface MembershipStatusResultNone { status: 'none' } diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 4deb36906dc..cd4d29facd1 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "5.6.0-preview.0", + "version": "5.6.0-preview.1", "description": "A api client for mx-space server@next", "type": "module", "engines": { From 6af4a352bfd5a0c6de2a2613bcc65807cc5515df Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 01:12:01 +0800 Subject: [PATCH 30/37] feat(membership): guard premium posts and expose plan availability --- .../modules/membership/entitlement.service.ts | 19 +++++- .../membership/membership.controller.ts | 7 ++ .../modules/membership/membership.types.ts | 18 +++++ apps/core/src/modules/post/post.controller.ts | 2 + .../membership/entitlement.service.spec.ts | 68 ++++++++++++++++++- .../membership.controller.e2e-spec.ts | 26 +++++++ .../membership/membership.types.spec.ts | 44 +++++++++++- .../src/modules/post/post-paywall.e2e-spec.ts | 36 +++++++++- 8 files changed, 214 insertions(+), 6 deletions(-) diff --git a/apps/core/src/modules/membership/entitlement.service.ts b/apps/core/src/modules/membership/entitlement.service.ts index 69fd4384bef..18925404088 100644 --- a/apps/core/src/modules/membership/entitlement.service.ts +++ b/apps/core/src/modules/membership/entitlement.service.ts @@ -2,11 +2,19 @@ import { Injectable } from '@nestjs/common' import type { EntityId } from '~/shared/id/entity-id' +import { ConfigsService } from '../configs/configs.service' import { MembershipRepository } from './membership.repository' +import { + type MembershipAvailability, + resolveMembershipAvailability, +} from './membership.types' @Injectable() export class EntitlementService { - constructor(private readonly membershipRepository: MembershipRepository) {} + constructor( + private readonly membershipRepository: MembershipRepository, + private readonly configsService: ConfigsService, + ) {} async isActiveMember(readerId: EntityId | string): Promise { const membership = await this.membershipRepository.findByReaderId(readerId) @@ -15,4 +23,13 @@ export class EntitlementService { return false return membership.currentPeriodEnd.getTime() > Date.now() } + + async getAvailability(): Promise { + const config = await this.configsService.get('membership') + return resolveMembershipAvailability(config) + } + + async isMembershipPurchasable(): Promise { + return (await this.getAvailability()).enabled + } } diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 48c4c63b2f2..896def434fb 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -28,6 +28,7 @@ import { ConfigsService } from '../configs/configs.service' import { MembershipService } from './membership.service' import { effectiveMembershipStatus, + resolveMembershipAvailability, resolveMembershipReturnUrl, } from './membership.types' import { DodoProvider } from './providers/dodo.provider' @@ -96,6 +97,12 @@ export class MembershipController { }) } + @Get('/plans') + async plans() { + const membershipConfig = await this.configsService.get('membership') + return resolveMembershipAvailability(membershipConfig) + } + @ReaderAuth() @Get('/status') async status(@Req() req: FastifyBizRequest) { diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 3d0cf32bed4..b30c75839c8 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -31,6 +31,24 @@ export function effectiveMembershipStatus( : 'expired' } +export interface MembershipAvailability { + enabled: boolean + plans: MembershipPlan[] +} + +export function resolveMembershipAvailability(config: { + enabled?: boolean + provider?: string + monthlyProductId?: string + yearlyProductId?: string +}): MembershipAvailability { + const plans: MembershipPlan[] = [] + if (config.monthlyProductId) plans.push('monthly') + if (config.yearlyProductId) plans.push('yearly') + const enabled = !!config.enabled && !!config.provider && plans.length > 0 + return { enabled, plans: enabled ? plans : [] } +} + export function resolveMembershipReturnUrl( returnPath: string | undefined, webUrl: string | undefined, diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index 46acaa0ba81..6f8f3810f65 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -92,6 +92,8 @@ export class PostController { ) { if (!doc.isPremium) return + if (!(await this.entitlementService.isMembershipPurchasable())) return + const isEntitled = isOwner || (readerId diff --git a/apps/core/test/src/modules/membership/entitlement.service.spec.ts b/apps/core/test/src/modules/membership/entitlement.service.spec.ts index 07c2494a071..b4aef69eefd 100644 --- a/apps/core/test/src/modules/membership/entitlement.service.spec.ts +++ b/apps/core/test/src/modules/membership/entitlement.service.spec.ts @@ -21,10 +21,23 @@ const createMembership = ( ...overrides, }) -const createService = () => { +const createService = ( + membershipConfig: Record = { + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', + }, +) => { const membershipRepository = createPgRepositoryMock() - const service = new EntitlementService(membershipRepository) - return { service, membershipRepository } + const configsService = { + get: vi.fn().mockResolvedValue(membershipConfig), + } + const service = new EntitlementService( + membershipRepository, + configsService as any, + ) + return { service, membershipRepository, configsService } } describe('EntitlementService.isActiveMember', () => { @@ -98,3 +111,52 @@ describe('EntitlementService.isActiveMember', () => { expect(await service.isActiveMember('reader-1')).toBe(false) }) }) + +describe('EntitlementService.getAvailability', () => { + it('reports enabled with both plans when fully configured', async () => { + const { service } = createService() + expect(await service.getAvailability()).toEqual({ + enabled: true, + plans: ['monthly', 'yearly'], + }) + expect(await service.isMembershipPurchasable()).toBe(true) + }) + + it('reports only configured plans', async () => { + const { service } = createService({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + }) + expect(await service.getAvailability()).toEqual({ + enabled: true, + plans: ['monthly'], + }) + }) + + it('is not purchasable when disabled', async () => { + const { service } = createService({ + enabled: false, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + }) + expect(await service.getAvailability()).toEqual({ + enabled: false, + plans: [], + }) + expect(await service.isMembershipPurchasable()).toBe(false) + }) + + it('is not purchasable when no product id is set', async () => { + const { service } = createService({ enabled: true, provider: 'dodo' }) + expect(await service.isMembershipPurchasable()).toBe(false) + }) + + it('is not purchasable when provider is missing', async () => { + const { service } = createService({ + enabled: true, + monthlyProductId: 'prod_monthly', + }) + expect(await service.isMembershipPurchasable()).toBe(false) + }) +}) diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index b51900c7463..9b05d80089e 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -283,6 +283,32 @@ describe('MembershipController (e2e)', () => { }) }) + describe('GET /membership/plans', () => { + it('returns availability without auth when configured', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/plans', + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ + data: { enabled: true, plans: ['monthly', 'yearly'] }, + }) + }) + + it('reports disabled with empty plans when membership is off', async () => { + membershipConfig.enabled = false + + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/plans', + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ data: { enabled: false, plans: [] } }) + }) + }) + describe('GET /membership/status', () => { it('returns a none shape when the reader has no membership', async () => { const res = await proxy.app.inject({ diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts index 38dad366c9d..dd39fdb4377 100644 --- a/apps/core/test/src/modules/membership/membership.types.spec.ts +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -1,6 +1,48 @@ import { describe, expect, it } from 'vitest' -import { resolveMembershipReturnUrl } from '~/modules/membership/membership.types' +import { + resolveMembershipAvailability, + resolveMembershipReturnUrl, +} from '~/modules/membership/membership.types' + +describe('resolveMembershipAvailability', () => { + it('is enabled with both plans when fully configured', () => { + expect( + resolveMembershipAvailability({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'm', + yearlyProductId: 'y', + }), + ).toEqual({ enabled: true, plans: ['monthly', 'yearly'] }) + }) + + it('lists only the plans whose product id is set', () => { + expect( + resolveMembershipAvailability({ + enabled: true, + provider: 'dodo', + yearlyProductId: 'y', + }), + ).toEqual({ enabled: true, plans: ['yearly'] }) + }) + + it('is disabled (empty plans) when toggle off, provider missing, or no product id', () => { + expect( + resolveMembershipAvailability({ + enabled: false, + provider: 'dodo', + monthlyProductId: 'm', + }), + ).toEqual({ enabled: false, plans: [] }) + expect( + resolveMembershipAvailability({ enabled: true, monthlyProductId: 'm' }), + ).toEqual({ enabled: false, plans: [] }) + expect( + resolveMembershipAvailability({ enabled: true, provider: 'dodo' }), + ).toEqual({ enabled: false, plans: [] }) + }) +}) describe('resolveMembershipReturnUrl', () => { const web = 'https://blog.example.com' diff --git a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts index 1d2187c0744..17af0a68ad0 100644 --- a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts @@ -144,6 +144,19 @@ const readerRoleById: Record = [expiredMemberReaderId]: { id: expiredMemberReaderId, role: 'reader' }, } +const membershipConfig: Record = { + enabled: true, + provider: 'dodo', + monthlyProductId: 'prod_monthly', + yearlyProductId: 'prod_yearly', +} + +const configsServiceMock = { + get: vi.fn(async (key: string) => + key === 'membership' ? membershipConfig : {}, + ), +} + const authServiceMock = { getSessionUser: vi.fn(async (req: { headers?: Record }) => { const readerId = req?.headers?.['x-test-reader-id'] as string | undefined @@ -216,7 +229,7 @@ const postModule: ModuleMetadata = { MembershipRepository, { provide: SnowflakeService, useValue: snowflake }, { provide: AuthService, useValue: authServiceMock }, - { provide: ConfigsService, useValue: {} }, + { provide: ConfigsService, useValue: configsServiceMock }, { provide: APP_GUARD, useClass: RolesGuard }, ], } @@ -316,6 +329,27 @@ describe('Post paywall enforcement (e2e)', () => { }) }) + it('guards a premium post to full content when membership is not purchasable', async () => { + currentPost = { ...premiumPostFixture } + membershipConfig.enabled = false + try { + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.category.slug}/${premiumPostFixture.slug}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall).toBeUndefined() + const fullContent = JSON.parse(body.data.content) + expect(fullContent.root.children).toHaveLength(5) + expect(body.data.text).toBe(FIVE_BLOCKS.join('\n\n')) + } finally { + membershipConfig.enabled = true + } + }) + it('boundary: a post with exactly N=3 blocks is a strict subset (2 blocks, locked:true) for a non-member', async () => { currentPost = { ...boundaryPostFixture } From 76d548b8cd6be1b2fcb82a2fe5070f72c82e5fbd Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 01:24:00 +0800 Subject: [PATCH 31/37] feat(membership): expose plan pricing from provider on plans endpoint --- .../membership/membership.controller.ts | 23 +++++- .../membership/providers/dodo.provider.ts | 72 +++++++++++++++++++ .../providers/provider.interface.ts | 9 +++ packages/api-client/models/membership.ts | 14 +++- packages/api-client/package.json | 2 +- 5 files changed, 117 insertions(+), 3 deletions(-) diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 896def434fb..c9317c2ecce 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -100,7 +100,28 @@ export class MembershipController { @Get('/plans') async plans() { const membershipConfig = await this.configsService.get('membership') - return resolveMembershipAvailability(membershipConfig) + const availability = resolveMembershipAvailability(membershipConfig) + if (!availability.enabled) return { enabled: false, plans: [] } + + const adapter = + membershipConfig.provider === 'dodo' ? this.dodoProvider : undefined + const productIdByPlan: Record = { + monthly: membershipConfig.monthlyProductId, + yearly: membershipConfig.yearlyProductId, + } + + const plans = await Promise.all( + availability.plans.map(async (plan) => { + const productId = productIdByPlan[plan] + const pricing = + adapter?.getPlanPricing && productId + ? await adapter.getPlanPricing(productId).catch(() => null) + : null + return { plan, pricing: pricing ?? undefined } + }), + ) + + return { enabled: true, plans } } @ReaderAuth() diff --git a/apps/core/src/modules/membership/providers/dodo.provider.ts b/apps/core/src/modules/membership/providers/dodo.provider.ts index 17c49a12990..13cd4be34bc 100644 --- a/apps/core/src/modules/membership/providers/dodo.provider.ts +++ b/apps/core/src/modules/membership/providers/dodo.provider.ts @@ -8,9 +8,27 @@ import { ConfigsService } from '../../configs/configs.service' import type { MembershipPlan } from '../membership.types' import type { NormalizedBillingEvent, + NormalizedPlanPricing, PaymentProviderAdapter, } from './provider.interface' +const PRICING_TTL_MS = 10 * 60 * 1000 + +const normalizeInterval = ( + interval: unknown, +): NormalizedPlanPricing['interval'] | null => { + const value = String(interval).toLowerCase() + if ( + value === 'day' || + value === 'week' || + value === 'month' || + value === 'year' + ) { + return value + } + return null +} + type DodoSubscriptionEvent = { type: | 'subscription.active' @@ -55,6 +73,10 @@ export class DodoProvider implements PaymentProviderAdapter { private client: DodoPayments | null = null private cachedApiKey: string | null = null private cachedEnvironment: 'test_mode' | 'live_mode' | null = null + private readonly pricingCache = new Map< + string, + { value: NormalizedPlanPricing | null; expiresAt: number } + >() constructor(private readonly configsService: ConfigsService) {} @@ -114,6 +136,56 @@ export class DodoProvider implements PaymentProviderAdapter { return { checkoutUrl: session.checkout_url } } + async getPlanPricing( + productId: string, + ): Promise { + const cached = this.pricingCache.get(productId) + if (cached && cached.expiresAt > Date.now()) return cached.value + + const membershipConfig = await this.configsService.get('membership') + if (!membershipConfig.dodoApiKey) return null + + const client = this.getClient( + membershipConfig.dodoApiKey, + membershipConfig.dodoEnvironment, + ) + + let value: NormalizedPlanPricing | null = null + try { + const product = await client.products.retrieve(productId) + const price = product.price as + | { + price?: number + currency?: string + payment_frequency_interval?: unknown + payment_frequency_count?: number + } + | undefined + const interval = normalizeInterval(price?.payment_frequency_interval) + if ( + price && + typeof price.price === 'number' && + price.currency && + interval + ) { + value = { + amount: price.price, + currency: price.currency, + interval, + intervalCount: price.payment_frequency_count ?? 1, + } + } + } catch { + value = null + } + + this.pricingCache.set(productId, { + value, + expiresAt: Date.now() + PRICING_TTL_MS, + }) + return value + } + async verifyAndParseWebhook( rawBody: Buffer | string, headers: Record, diff --git a/apps/core/src/modules/membership/providers/provider.interface.ts b/apps/core/src/modules/membership/providers/provider.interface.ts index 7e2a7125080..d733977c059 100644 --- a/apps/core/src/modules/membership/providers/provider.interface.ts +++ b/apps/core/src/modules/membership/providers/provider.interface.ts @@ -11,6 +11,13 @@ export interface NormalizedBillingEvent { readerId: string } +export interface NormalizedPlanPricing { + amount: number + currency: string + interval: 'day' | 'week' | 'month' | 'year' + intervalCount: number +} + export interface PaymentProviderAdapter { createCheckout: (input: { reader: { id: string; email?: string | null; name?: string | null } @@ -18,6 +25,8 @@ export interface PaymentProviderAdapter { returnUrl?: string }) => Promise<{ checkoutUrl: string }> + getPlanPricing?: (productId: string) => Promise + verifyAndParseWebhook: ( rawBody: Buffer | string, headers: Record, diff --git a/packages/api-client/models/membership.ts b/packages/api-client/models/membership.ts index 7b7b72179f9..cd4cc370d32 100644 --- a/packages/api-client/models/membership.ts +++ b/packages/api-client/models/membership.ts @@ -9,9 +9,21 @@ export interface MembershipCheckoutResult { checkoutUrl: string } +export interface MembershipPlanPricing { + amount: number + currency: string + interval: 'day' | 'week' | 'month' | 'year' + intervalCount: number +} + +export interface MembershipPlanInfo { + plan: MembershipPlan + pricing?: MembershipPlanPricing +} + export interface MembershipPlansResult { enabled: boolean - plans: MembershipPlan[] + plans: MembershipPlanInfo[] } export interface MembershipStatusResultNone { diff --git a/packages/api-client/package.json b/packages/api-client/package.json index cd4d29facd1..50ebe0b1c6b 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "5.6.0-preview.1", + "version": "5.6.0-preview.2", "description": "A api client for mx-space server@next", "type": "module", "engines": { From d832e87b91ac5c6c00ee18c6507869e14c85ea50 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 01:37:02 +0800 Subject: [PATCH 32/37] refactor(membership): provider registry so pricing/checkout/webhook aren't dodo-coupled --- .../membership/membership.controller.ts | 19 +++---- .../modules/membership/membership.module.ts | 2 + .../membership/providers/provider.registry.ts | 31 ++++++++++++ .../modules/membership/dodo.provider.spec.ts | 46 +++++++++++++++++ .../membership.controller.e2e-spec.ts | 50 ++++++++++++++++++- 5 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 apps/core/src/modules/membership/providers/provider.registry.ts diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index c9317c2ecce..10c541219d7 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -31,8 +31,7 @@ import { resolveMembershipAvailability, resolveMembershipReturnUrl, } from './membership.types' -import { DodoProvider } from './providers/dodo.provider' -import type { PaymentProviderAdapter } from './providers/provider.interface' +import { PaymentProviderRegistry } from './providers/provider.registry' const CheckoutSchema = z.object({ plan: z.enum(['monthly', 'yearly']), @@ -68,14 +67,9 @@ export class MembershipController { constructor( private readonly membershipService: MembershipService, private readonly configsService: ConfigsService, - private readonly dodoProvider: DodoProvider, + private readonly providers: PaymentProviderRegistry, ) {} - private resolveProvider(provider: string): PaymentProviderAdapter { - if (provider === 'dodo') return this.dodoProvider - throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) - } - @ReaderAuth() @Post('/checkout') async checkout(@Body() body: CheckoutDto, @CurrentUser() user: SessionUser) { @@ -89,7 +83,7 @@ export class MembershipController { const { webUrl } = await this.configsService.get('url') const returnUrl = resolveMembershipReturnUrl(body.returnPath, webUrl) - const adapter = this.resolveProvider(membershipConfig.provider) + const adapter = this.providers.resolve(membershipConfig.provider) return adapter.createCheckout({ reader: { id: user.id, email: user.email, name: user.name }, plan: body.plan, @@ -103,8 +97,7 @@ export class MembershipController { const availability = resolveMembershipAvailability(membershipConfig) if (!availability.enabled) return { enabled: false, plans: [] } - const adapter = - membershipConfig.provider === 'dodo' ? this.dodoProvider : undefined + const adapter = this.providers.get(membershipConfig.provider) const productIdByPlan: Record = { monthly: membershipConfig.monthlyProductId, yearly: membershipConfig.yearlyProductId, @@ -149,10 +142,10 @@ export class MembershipController { ) { assertNotDemoMode() - if (provider !== 'dodo') { + const adapter = this.providers.get(provider) + if (!adapter) { throw createAppException(AppErrorCode.WEBHOOK_VERIFY_FAILED) } - const adapter = this.dodoProvider const rawBody = req.rawBody ?? Buffer.alloc(0) const event = await adapter.verifyAndParseWebhook(rawBody, headers) const result = await this.membershipService.applyEvent(event) diff --git a/apps/core/src/modules/membership/membership.module.ts b/apps/core/src/modules/membership/membership.module.ts index 397e25a096d..4112419a534 100644 --- a/apps/core/src/modules/membership/membership.module.ts +++ b/apps/core/src/modules/membership/membership.module.ts @@ -6,6 +6,7 @@ import { MembershipController } from './membership.controller' import { MembershipRepository } from './membership.repository' import { MembershipService } from './membership.service' import { DodoProvider } from './providers/dodo.provider' +import { PaymentProviderRegistry } from './providers/provider.registry' @Module({ controllers: [MembershipController], @@ -15,6 +16,7 @@ import { DodoProvider } from './providers/dodo.provider' MembershipRepository, BillingWebhookEventRepository, DodoProvider, + PaymentProviderRegistry, ], exports: [EntitlementService], }) diff --git a/apps/core/src/modules/membership/providers/provider.registry.ts b/apps/core/src/modules/membership/providers/provider.registry.ts new file mode 100644 index 00000000000..c7a13c32906 --- /dev/null +++ b/apps/core/src/modules/membership/providers/provider.registry.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common' + +import { AppErrorCode, createAppException } from '~/common/errors' + +import type { MembershipProvider } from '../membership.types' +import { DodoProvider } from './dodo.provider' +import type { PaymentProviderAdapter } from './provider.interface' + +@Injectable() +export class PaymentProviderRegistry { + private readonly registry: Partial< + Record + > + + constructor(dodoProvider: DodoProvider) { + this.registry = { dodo: dodoProvider } + } + + get(provider?: string | null): PaymentProviderAdapter | undefined { + if (!provider) return undefined + return this.registry[provider as MembershipProvider] + } + + resolve(provider?: string | null): PaymentProviderAdapter { + const adapter = this.get(provider) + if (!adapter) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + return adapter + } +} diff --git a/apps/core/test/src/modules/membership/dodo.provider.spec.ts b/apps/core/test/src/modules/membership/dodo.provider.spec.ts index d8bd3a81364..69505ff0c06 100644 --- a/apps/core/test/src/modules/membership/dodo.provider.spec.ts +++ b/apps/core/test/src/modules/membership/dodo.provider.spec.ts @@ -5,6 +5,7 @@ import { DodoProvider } from '~/modules/membership/providers/dodo.provider' const verifyMock = vi.fn() const checkoutCreateMock = vi.fn() +const productsRetrieveMock = vi.fn() vi.mock('standardwebhooks', () => ({ Webhook: class MockWebhook { @@ -15,6 +16,7 @@ vi.mock('standardwebhooks', () => ({ vi.mock('dodopayments', () => ({ default: class MockDodoPayments { checkoutSessions = { create: checkoutCreateMock } + products = { retrieve: productsRetrieveMock } }, })) @@ -24,6 +26,7 @@ describe('DodoProvider', () => { beforeEach(() => { verifyMock.mockReset() checkoutCreateMock.mockReset() + productsRetrieveMock.mockReset() configsService = { get: vi.fn().mockResolvedValue({ @@ -167,6 +170,49 @@ describe('DodoProvider', () => { }) }) + describe('getPlanPricing', () => { + it('normalizes a recurring price and caches it', async () => { + productsRetrieveMock.mockResolvedValue({ + name: 'VIP', + price: { + price: 500, + currency: 'USD', + payment_frequency_interval: 'Month', + payment_frequency_count: 1, + }, + }) + + const provider = new DodoProvider(configsService as any) + const first = await provider.getPlanPricing('prod_monthly') + const second = await provider.getPlanPricing('prod_monthly') + + expect(first).toEqual({ + amount: 500, + currency: 'USD', + interval: 'month', + intervalCount: 1, + }) + expect(second).toEqual(first) + expect(productsRetrieveMock).toHaveBeenCalledTimes(1) + }) + + it('returns null when the provider call throws', async () => { + productsRetrieveMock.mockRejectedValue(new Error('boom')) + + const provider = new DodoProvider(configsService as any) + expect(await provider.getPlanPricing('prod_x')).toBeNull() + }) + + it('returns null when the price is not recurring', async () => { + productsRetrieveMock.mockResolvedValue({ + price: { price: 500, currency: 'USD', type: 'one_time_price' }, + }) + + const provider = new DodoProvider(configsService as any) + expect(await provider.getPlanPricing('prod_y')).toBeNull() + }) + }) + describe('verifyAndParseWebhook', () => { const headers = { 'webhook-id': 'evt_1', diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 9b05d80089e..f96babd9fe5 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -23,6 +23,7 @@ import { MembershipController } from '~/modules/membership/membership.controller import { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' import { DodoProvider } from '~/modules/membership/providers/dodo.provider' +import { PaymentProviderRegistry } from '~/modules/membership/providers/provider.registry' import type { AppDatabase } from '~/processors/database/postgres.provider' import { SnowflakeService } from '~/shared/id/snowflake.service' @@ -109,9 +110,17 @@ const verifyAndParseWebhookMock = vi.fn( }, ) +const getPlanPricingMock = vi.fn(async (productId: string) => ({ + amount: productId === 'prod_monthly' ? 500 : 5000, + currency: 'USD', + interval: productId === 'prod_monthly' ? 'month' : 'year', + intervalCount: 1, +})) + const dodoProviderMock = { createCheckout: createCheckoutMock, verifyAndParseWebhook: verifyAndParseWebhookMock, + getPlanPricing: getPlanPricingMock, } const membershipModule: ModuleMetadata = { @@ -123,6 +132,7 @@ const membershipModule: ModuleMetadata = { EntitlementService, { provide: SnowflakeService, useValue: snowflake }, { provide: DodoProvider, useValue: dodoProviderMock }, + PaymentProviderRegistry, { provide: AuthService, useValue: authServiceMock }, { provide: ConfigsService, useValue: configsServiceMock }, ], @@ -284,7 +294,7 @@ describe('MembershipController (e2e)', () => { }) describe('GET /membership/plans', () => { - it('returns availability without auth when configured', async () => { + it('returns availability with pricing, without auth, when configured', async () => { const res = await proxy.app.inject({ method: 'GET', url: '/membership/plans', @@ -292,10 +302,46 @@ describe('MembershipController (e2e)', () => { expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ - data: { enabled: true, plans: ['monthly', 'yearly'] }, + data: { + enabled: true, + plans: [ + { + plan: 'monthly', + pricing: { + amount: 500, + currency: 'USD', + interval: 'month', + interval_count: 1, + }, + }, + { + plan: 'yearly', + pricing: { + amount: 5000, + currency: 'USD', + interval: 'year', + interval_count: 1, + }, + }, + ], + }, }) }) + it('omits pricing for a plan when the provider lookup fails', async () => { + getPlanPricingMock.mockResolvedValueOnce(null as any) + + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/plans', + }) + + expect(res.statusCode).toBe(200) + const plans = res.json().data.plans + expect(plans[0]).toEqual({ plan: 'monthly' }) + expect(plans[1].pricing).toBeDefined() + }) + it('reports disabled with empty plans when membership is off', async () => { membershipConfig.enabled = false From 0f9bf74cddd6364a5adc10865f5425498719cfec Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 02:40:43 +0800 Subject: [PATCH 33/37] feat(membership): expose isMember on comment authors via batch entitlement --- .../src/modules/comment/comment.controller.ts | 22 +++++++++-- .../src/modules/comment/comment.module.ts | 2 + .../modules/membership/entitlement.service.ts | 17 ++++++++ .../membership/membership.repository.ts | 13 ++++++- .../comment/comment-list-tab-param.spec.ts | 7 ++++ .../comment/comment.controller.spec.ts | 7 ++++ .../membership/entitlement.service.spec.ts | 39 +++++++++++++++++++ packages/api-client/models/reader.ts | 2 + packages/api-client/package.json | 2 +- 9 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/core/src/modules/comment/comment.controller.ts b/apps/core/src/modules/comment/comment.controller.ts index ce44f347b87..02f4684a7c0 100644 --- a/apps/core/src/modules/comment/comment.controller.ts +++ b/apps/core/src/modules/comment/comment.controller.ts @@ -31,6 +31,7 @@ import { EntityIdDto } from '~/shared/dto/id.dto' import { BasicPagerDto } from '~/shared/dto/pager.dto' import { ConfigsService } from '../configs/configs.service' +import { EntitlementService } from '../membership/entitlement.service' import { ReaderService } from '../reader/reader.service' import { CommentFilterEmailInterceptor } from './comment.interceptor' import { CommentLifecycleService } from './comment.lifecycle.service' @@ -68,8 +69,21 @@ export class CommentController { private readonly configsService: ConfigsService, @Inject(forwardRef(() => ReaderService)) private readonly readerService: ReaderService, + private readonly entitlementService: EntitlementService, ) {} + private async withMembership( + readers: T[], + ): Promise<(T & { isMember: boolean })[]> { + const memberIds = await this.entitlementService.getActiveMemberIds( + readers.map((reader) => String(reader.id)), + ) + return readers.map((reader) => ({ + ...reader, + isMember: memberIds.has(String(reader.id)), + })) + } + private buildAdminCommentFilter(input: { currentState?: number refId?: string @@ -166,7 +180,7 @@ export class CommentController { comments.data.map((doc) => doc.readerId).filter(Boolean) as string[], ) - const readerMap = keyBy(readers, 'id') + const readerMap = keyBy(await this.withMembership(readers), 'id') const data = comments.data.map((doc) => ({ ...doc, reader: readerMap[(doc as any).readerId] ?? null, @@ -240,7 +254,7 @@ export class CommentController { const readerIds = this.commentService.collectThreadReaderIds(comments.data) const readers = await this.readerService.findReaderInIds(readerIds) - const readerMap2 = keyBy(readers, 'id') + const readerMap2 = keyBy(await this.withMembership(readers), 'id') const refData = comments.data.map((doc) => ({ ...doc, reader: readerMap2[(doc as any).readerId] ?? null, @@ -305,7 +319,9 @@ export class CommentController { await this.commentService.fillAndReplaceAvatarUrl([data]) if (data.readerId) { - const reader = await this.readerService.findReaderInIds([data.readerId]) + const reader = await this.withMembership( + await this.readerService.findReaderInIds([data.readerId]), + ) Object.assign(data, { reader: reader[0] }) } diff --git a/apps/core/src/modules/comment/comment.module.ts b/apps/core/src/modules/comment/comment.module.ts index bf9d992958c..d1c3b6917be 100644 --- a/apps/core/src/modules/comment/comment.module.ts +++ b/apps/core/src/modules/comment/comment.module.ts @@ -3,6 +3,7 @@ import { forwardRef, Module } from '@nestjs/common' import { GatewayModule } from '~/processors/gateway/gateway.module' import { AiModule } from '../ai/ai.module' +import { MembershipModule } from '../membership/membership.module' import { OwnerModule } from '../owner/owner.module' import { ReaderModule } from '../reader/reader.module' import { ServerlessModule } from '../serverless/serverless.module' @@ -38,6 +39,7 @@ import { CommentReaderFillService } from './comment-reader-fill.service' imports: [ OwnerModule, GatewayModule, + MembershipModule, forwardRef(() => ServerlessModule), forwardRef(() => ReaderModule), forwardRef(() => AiModule), diff --git a/apps/core/src/modules/membership/entitlement.service.ts b/apps/core/src/modules/membership/entitlement.service.ts index 18925404088..fda3b7c8364 100644 --- a/apps/core/src/modules/membership/entitlement.service.ts +++ b/apps/core/src/modules/membership/entitlement.service.ts @@ -24,6 +24,23 @@ export class EntitlementService { return membership.currentPeriodEnd.getTime() > Date.now() } + async getActiveMemberIds( + readerIds: (EntityId | string)[], + ): Promise> { + const unique = [...new Set(readerIds.map(String))] + if (unique.length === 0) return new Set() + const rows = await this.membershipRepository.findByReaderIds(unique) + const now = Date.now() + const active = new Set() + for (const row of rows) { + const entitled = + (row.status === 'active' || row.status === 'on_hold') && + row.currentPeriodEnd.getTime() > now + if (entitled) active.add(String(row.readerId)) + } + return active + } + async getAvailability(): Promise { const config = await this.configsService.get('membership') return resolveMembershipAvailability(config) diff --git a/apps/core/src/modules/membership/membership.repository.ts b/apps/core/src/modules/membership/membership.repository.ts index e4a0e2fc7bf..e7c8bd999a3 100644 --- a/apps/core/src/modules/membership/membership.repository.ts +++ b/apps/core/src/modules/membership/membership.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' -import { desc, eq, sql } from 'drizzle-orm' +import { desc, eq, inArray, sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' import { memberships, readers } from '~/database/schema' @@ -88,6 +88,17 @@ export class MembershipRepository extends BaseRepository { return row ? mapRow(row) : null } + async findByReaderIds( + readerIds: (EntityId | string)[], + ): Promise { + if (readerIds.length === 0) return [] + const rows = await this.db + .select() + .from(memberships) + .where(inArray(memberships.readerId, readerIds.map(parseEntityId))) + return rows.map(mapRow) + } + async findByProviderSubscriptionId( providerSubscriptionId: string, ): Promise { diff --git a/apps/core/test/src/modules/comment/comment-list-tab-param.spec.ts b/apps/core/test/src/modules/comment/comment-list-tab-param.spec.ts index 8e362a09254..b15e85612ff 100644 --- a/apps/core/test/src/modules/comment/comment-list-tab-param.spec.ts +++ b/apps/core/test/src/modules/comment/comment-list-tab-param.spec.ts @@ -7,6 +7,7 @@ import { CommentController } from '~/modules/comment/comment.controller' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' import { CommentService } from '~/modules/comment/comment.service' import { ConfigsService } from '~/modules/configs/configs.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { ReaderService } from '~/modules/reader/reader.service' import { EventManagerService } from '~/processors/helper/helper.event.service' @@ -66,6 +67,12 @@ describe('CommentController list tab/state/author parameters', () => { provide: ReaderService, useValue: { findReaderInIds: vi.fn().mockResolvedValue([]) }, }, + { + provide: EntitlementService, + useValue: { + getActiveMemberIds: vi.fn().mockResolvedValue(new Set()), + }, + }, ], }).compile() diff --git a/apps/core/test/src/modules/comment/comment.controller.spec.ts b/apps/core/test/src/modules/comment/comment.controller.spec.ts index 003dafd7f41..4cd0cdc154a 100644 --- a/apps/core/test/src/modules/comment/comment.controller.spec.ts +++ b/apps/core/test/src/modules/comment/comment.controller.spec.ts @@ -11,6 +11,7 @@ import { CommentController } from '~/modules/comment/comment.controller' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' import { CommentService } from '~/modules/comment/comment.service' import { ConfigsService } from '~/modules/configs/configs.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { ReaderService } from '~/modules/reader/reader.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import type { BizIncomingMessage } from '~/transformers/get-req.transformer' @@ -80,6 +81,12 @@ describe('CommentController permission gating', () => { findReaderInIds: vi.fn().mockResolvedValue([]), }, }, + { + provide: EntitlementService, + useValue: { + getActiveMemberIds: vi.fn().mockResolvedValue(new Set()), + }, + }, ], }).compile() diff --git a/apps/core/test/src/modules/membership/entitlement.service.spec.ts b/apps/core/test/src/modules/membership/entitlement.service.spec.ts index b4aef69eefd..4aaacee699f 100644 --- a/apps/core/test/src/modules/membership/entitlement.service.spec.ts +++ b/apps/core/test/src/modules/membership/entitlement.service.spec.ts @@ -112,6 +112,45 @@ describe('EntitlementService.isActiveMember', () => { }) }) +describe('EntitlementService.getActiveMemberIds', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('returns only reader ids with a live membership', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderIds.mockResolvedValue([ + createMembership({ readerId: 'r-active' as any, status: 'active' }), + createMembership({ + readerId: 'r-expired' as any, + status: 'active', + currentPeriodEnd: new Date(now.getTime() - 1000), + }), + createMembership({ readerId: 'r-cancelled' as any, status: 'cancelled' }), + ]) + + const result = await service.getActiveMemberIds([ + 'r-active', + 'r-expired', + 'r-cancelled', + 'r-none', + ]) + + expect([...result]).toEqual(['r-active']) + }) + + it('returns an empty set for no ids without hitting the repository', async () => { + const { service, membershipRepository } = createService() + const result = await service.getActiveMemberIds([]) + expect(result.size).toBe(0) + expect(membershipRepository.findByReaderIds).not.toHaveBeenCalled() + }) +}) + describe('EntitlementService.getAvailability', () => { it('reports enabled with both plans when fully configured', async () => { const { service } = createService() diff --git a/packages/api-client/models/reader.ts b/packages/api-client/models/reader.ts index 9cb9d8d2a03..e457ea38bda 100644 --- a/packages/api-client/models/reader.ts +++ b/packages/api-client/models/reader.ts @@ -6,4 +6,6 @@ export interface ReaderModel { image: string role?: 'reader' | 'owner' + + isMember?: boolean } diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 50ebe0b1c6b..511da26f8bc 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "5.6.0-preview.2", + "version": "5.6.0-preview.3", "description": "A api client for mx-space server@next", "type": "module", "engines": { From f3d5b6ebd9640f8acbb5cf2a2af18ed91b024b7a Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 13:45:34 +0800 Subject: [PATCH 34/37] refactor(membership): declare webhook raw-body capture via route options instead of hardcoded path match --- .../src/common/adapters/fastify.adapter.ts | 26 +++++--- .../fastify-route-options.decorator.ts | 4 +- .../membership/membership.controller.ts | 2 + .../adapters/fastify-adapter-raw-body.spec.ts | 64 ++++++++++++------- 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/apps/core/src/common/adapters/fastify.adapter.ts b/apps/core/src/common/adapters/fastify.adapter.ts index df1588bdb70..0ca2ccb5bb5 100644 --- a/apps/core/src/common/adapters/fastify.adapter.ts +++ b/apps/core/src/common/adapters/fastify.adapter.ts @@ -68,18 +68,21 @@ app.register(FastifyMultipart, { }, }) -const MEMBERSHIP_WEBHOOK_PATH_SEGMENT = '/membership/webhook/' -const MEMBERSHIP_WEBHOOK_RAW_BODY_LIMIT = 1024 * 1024 +const DEFAULT_RAW_BODY_LIMIT = 1024 * 1024 -export function isMembershipWebhookRequest(url: string): boolean { - const path = url.split('?', 1)[0] - return path.includes(MEMBERSHIP_WEBHOOK_PATH_SEGMENT) +export function isRawBodyRoute(request: FastifyRequest): boolean { + const config = request.routeOptions?.config as unknown as + Record | undefined + const options = config?.[FASTIFY_ROUTE_OPTIONS_CONFIG] as + FastifyRouteOptions | undefined + return options?.rawBody === true } -export async function membershipWebhookRawBodyPreParsingHook( +export async function collectRawBodyPreParsingHook( request: FastifyRequest, reply: { code: (statusCode: number) => { send: (payload: unknown) => void } }, payload: AsyncIterable, + limit = DEFAULT_RAW_BODY_LIMIT, ): Promise { const chunks: Buffer[] = [] let total = 0 @@ -88,7 +91,7 @@ export async function membershipWebhookRawBodyPreParsingHook( const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) total += buf.byteLength - if (total > MEMBERSHIP_WEBHOOK_RAW_BODY_LIMIT) { + if (total > limit) { reply.code(413).send({ error: { code: 'PAYLOAD_TOO_LARGE', @@ -108,11 +111,16 @@ export async function membershipWebhookRawBodyPreParsingHook( } app.getInstance().addHook('preParsing', async (request, reply, payload) => { - if (!isMembershipWebhookRequest(request.url)) { + if (!isRawBodyRoute(request)) { return payload } - return membershipWebhookRawBodyPreParsingHook(request, reply, payload) + return collectRawBodyPreParsingHook( + request, + reply, + payload, + request.routeOptions?.bodyLimit ?? DEFAULT_RAW_BODY_LIMIT, + ) }) app.getInstance().addHook('onRequest', (request, reply, done) => { diff --git a/apps/core/src/common/decorators/fastify-route-options.decorator.ts b/apps/core/src/common/decorators/fastify-route-options.decorator.ts index 8c77d3fd7e1..958d7dc5cdb 100644 --- a/apps/core/src/common/decorators/fastify-route-options.decorator.ts +++ b/apps/core/src/common/decorators/fastify-route-options.decorator.ts @@ -8,7 +8,9 @@ export const FASTIFY_ROUTE_OPTIONS_CONFIG = Symbol( export type FastifyRouteOptions = Pick< RouteShorthandOptions, 'bodyLimit' | 'errorHandler' -> +> & { + rawBody?: boolean +} export const WithFastifyRouteOptions = (options: FastifyRouteOptions) => RouteConfig({ [FASTIFY_ROUTE_OPTIONS_CONFIG]: options }) diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 10c541219d7..8c8cbe5d4c0 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -16,6 +16,7 @@ import { DEMO_MODE } from '~/app.config' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { CurrentUser } from '~/common/decorators/current-user.decorator' +import { WithFastifyRouteOptions } from '~/common/decorators/fastify-route-options.decorator' import { ReaderAuth } from '~/common/decorators/reader-auth.decorator' import { AppErrorCode, createAppException } from '~/common/errors' import { withMeta } from '~/common/response/envelope.types' @@ -135,6 +136,7 @@ export class MembershipController { } @Post('/webhook/:provider') + @WithFastifyRouteOptions({ rawBody: true }) async webhook( @Param('provider') provider: string, @Req() req: FastifyBizRequest, diff --git a/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts b/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts index e73c8b160fd..df4bd7cd16b 100644 --- a/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts +++ b/apps/core/test/src/common/adapters/fastify-adapter-raw-body.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { - isMembershipWebhookRequest, - membershipWebhookRawBodyPreParsingHook, + collectRawBodyPreParsingHook, + isRawBodyRoute, } from '~/common/adapters/fastify.adapter' +import { FASTIFY_ROUTE_OPTIONS_CONFIG } from '~/common/decorators/fastify-route-options.decorator' const createPayload = (chunks: Buffer[]) => ({ async *[Symbol.asyncIterator]() { @@ -28,31 +29,32 @@ const createReply = () => { } } -describe('isMembershipWebhookRequest', () => { - it('matches the membership webhook path prefix', () => { - expect(isMembershipWebhookRequest('/membership/webhook/dodo')).toBe(true) - }) - - it('matches even when a query string is present', () => { - expect( - isMembershipWebhookRequest( - '/membership/webhook/dodo?foo=/membership/webhook/', - ), - ).toBe(true) +describe('isRawBodyRoute', () => { + it('matches a route whose config opts into rawBody', () => { + const request: any = { + routeOptions: { + config: { [FASTIFY_ROUTE_OPTIONS_CONFIG]: { rawBody: true } }, + }, + } + expect(isRawBodyRoute(request)).toBe(true) }) - it('does not match an unrelated path that merely contains the segment in its query string', () => { - expect( - isMembershipWebhookRequest('/other/route?next=/membership/webhook/dodo'), - ).toBe(false) + it('does not match a route with options but no rawBody flag', () => { + const request: any = { + routeOptions: { + config: { [FASTIFY_ROUTE_OPTIONS_CONFIG]: { bodyLimit: 1024 } }, + }, + } + expect(isRawBodyRoute(request)).toBe(false) }) - it('does not match unrelated paths', () => { - expect(isMembershipWebhookRequest('/posts/1')).toBe(false) + it('does not match a route without route options config', () => { + expect(isRawBodyRoute({ routeOptions: { config: {} } } as any)).toBe(false) + expect(isRawBodyRoute({} as any)).toBe(false) }) }) -describe('membershipWebhookRawBodyPreParsingHook', () => { +describe('collectRawBodyPreParsingHook', () => { it('buffers the full body and attaches it as request.rawBody', async () => { const request: any = {} const reply = createReply() @@ -61,7 +63,7 @@ describe('membershipWebhookRawBodyPreParsingHook', () => { Buffer.from('"evt_1"}'), ]) - const stream = await membershipWebhookRawBodyPreParsingHook( + const stream = await collectRawBodyPreParsingHook( request, reply as any, payload as any, @@ -79,14 +81,14 @@ describe('membershipWebhookRawBodyPreParsingHook', () => { expect(reply.code).not.toHaveBeenCalled() }) - it('aborts with 413 once the accumulated body exceeds 1 MiB', async () => { + it('aborts with 413 once the accumulated body exceeds the limit', async () => { const request: any = {} const reply = createReply() const oneMib = 1024 * 1024 const chunks = [Buffer.alloc(oneMib, 'a'), Buffer.alloc(16, 'b')] const payload = createPayload(chunks) - const stream = await membershipWebhookRawBodyPreParsingHook( + const stream = await collectRawBodyPreParsingHook( request, reply as any, payload as any, @@ -104,4 +106,20 @@ describe('membershipWebhookRawBodyPreParsingHook', () => { } expect(Buffer.concat(collected).length).toBe(0) }) + + it('respects a route-level limit override', async () => { + const request: any = {} + const reply = createReply() + const payload = createPayload([Buffer.alloc(32, 'a')]) + + await collectRawBodyPreParsingHook( + request, + reply as any, + payload as any, + 16, + ) + + expect(reply.code).toHaveBeenCalledWith(413) + expect(request.rawBody).toBeUndefined() + }) }) From c46c0d6edfb5c22a8307fb7d35dca8d7f3299b97 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 14:02:29 +0800 Subject: [PATCH 35/37] fix: address codex review findings on paywall and membership - treat active provider subscriptions past currentPeriodEnd as dead so admin manual grants are not blocked - refuse /membership/checkout when the reader already has a live membership (new MEMBERSHIP_ALREADY_ACTIVE error) - persist the selected plan in dodo checkout metadata and prefer it over the optional webhook interval field - disable membership availability for providers without a registered adapter - omit AI summary meta and build enrichments from the truncated teaser for locked premium posts - strip text/summary from visitor translation broadcasts of premium posts - fold premium projection into translated search doc source hash and refresh translation docs on article update events --- apps/core/src/common/errors/app-error-code.ts | 1 + .../common/errors/app-error-definitions.ts | 4 + .../src/common/errors/app-error-payload.ts | 1 + .../membership/membership.controller.ts | 8 ++ .../modules/membership/membership.service.ts | 4 +- .../modules/membership/membership.types.ts | 8 +- .../membership/providers/dodo.provider.ts | 14 ++- apps/core/src/modules/post/post.controller.ts | 39 +++++---- .../modules/search/search-document.util.ts | 21 +++-- .../core/src/modules/search/search.service.ts | 12 ++- .../web/visitor-event-dispatch.service.ts | 34 ++++++-- .../helper/helper.event-payload.service.ts | 6 ++ .../modules/membership/dodo.provider.spec.ts | 21 ++++- .../membership.controller.e2e-spec.ts | 85 +++++++++++++++++++ .../membership/membership.service.spec.ts | 17 ++++ .../membership/membership.types.spec.ts | 10 +++ .../src/modules/post/post-paywall.e2e-spec.ts | 51 +++++++++++ .../search/search-document.util.spec.ts | 18 ++++ .../visitor-event-dispatch.service.spec.ts | 41 ++++++++- 19 files changed, 352 insertions(+), 43 deletions(-) diff --git a/apps/core/src/common/errors/app-error-code.ts b/apps/core/src/common/errors/app-error-code.ts index cbfb2a43328..98cec5e68c3 100644 --- a/apps/core/src/common/errors/app-error-code.ts +++ b/apps/core/src/common/errors/app-error-code.ts @@ -157,6 +157,7 @@ export enum AppErrorCode { MEMBERSHIP_REQUIRED = 'MEMBERSHIP_REQUIRED', WEBHOOK_VERIFY_FAILED = 'WEBHOOK_VERIFY_FAILED', MEMBERSHIP_PROVIDER_NOT_CONFIGURED = 'MEMBERSHIP_PROVIDER_NOT_CONFIGURED', + MEMBERSHIP_ALREADY_ACTIVE = 'MEMBERSHIP_ALREADY_ACTIVE', PREMIUM_REQUIRES_LEXICAL = 'PREMIUM_REQUIRES_LEXICAL', // page diff --git a/apps/core/src/common/errors/app-error-definitions.ts b/apps/core/src/common/errors/app-error-definitions.ts index 8889f59d076..d26209f3958 100644 --- a/apps/core/src/common/errors/app-error-definitions.ts +++ b/apps/core/src/common/errors/app-error-definitions.ts @@ -574,6 +574,10 @@ export const APP_ERROR_DEFINITIONS = { status: 400, message: 'No membership payment provider is configured', }, + [AppErrorCode.MEMBERSHIP_ALREADY_ACTIVE]: { + status: 409, + message: 'Reader already has an active membership', + }, [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: { status: 400, message: 'Only Lexical-format posts can be marked as premium', diff --git a/apps/core/src/common/errors/app-error-payload.ts b/apps/core/src/common/errors/app-error-payload.ts index cde22601d7f..1ae740d4ff0 100644 --- a/apps/core/src/common/errors/app-error-payload.ts +++ b/apps/core/src/common/errors/app-error-payload.ts @@ -163,6 +163,7 @@ export type AppErrorPayloadMap = { [AppErrorCode.MEMBERSHIP_REQUIRED]: undefined [AppErrorCode.WEBHOOK_VERIFY_FAILED]: undefined [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED]: undefined + [AppErrorCode.MEMBERSHIP_ALREADY_ACTIVE]: undefined [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: undefined // page diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 8c8cbe5d4c0..e107dccf83c 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -81,6 +81,14 @@ export class MembershipController { throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) } + const existing = await this.membershipService.getByReaderId(user.id) + if (existing) { + const status = effectiveMembershipStatus(existing) + if (status === 'active' || status === 'on_hold') { + throw createAppException(AppErrorCode.MEMBERSHIP_ALREADY_ACTIVE) + } + } + const { webUrl } = await this.configsService.get('url') const returnUrl = resolveMembershipReturnUrl(body.returnPath, webUrl) diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index cf6b25ac865..def17ed3a99 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -10,8 +10,8 @@ import type { NormalizedBillingEvent } from './providers/provider.interface' const isLiveProviderSubscription = (row: MembershipRow): boolean => { if (row.provider === 'manual') return false - if (row.status === 'active') return true - return row.status === 'on_hold' && row.currentPeriodEnd.getTime() > Date.now() + if (row.status !== 'active' && row.status !== 'on_hold') return false + return row.currentPeriodEnd.getTime() > Date.now() } @Injectable() diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index b30c75839c8..3b9d033870a 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -3,6 +3,8 @@ import type { EntityId } from '~/shared/id/entity-id' export type MembershipProvider = 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' +export const REGISTERED_PAYMENT_PROVIDERS: readonly string[] = ['dodo'] + export type MembershipPlan = 'monthly' | 'yearly' export type MembershipStatus = 'active' | 'on_hold' | 'cancelled' | 'expired' @@ -45,7 +47,11 @@ export function resolveMembershipAvailability(config: { const plans: MembershipPlan[] = [] if (config.monthlyProductId) plans.push('monthly') if (config.yearlyProductId) plans.push('yearly') - const enabled = !!config.enabled && !!config.provider && plans.length > 0 + const enabled = + !!config.enabled && + !!config.provider && + REGISTERED_PAYMENT_PROVIDERS.includes(config.provider) && + plans.length > 0 return { enabled, plans: enabled ? plans : [] } } diff --git a/apps/core/src/modules/membership/providers/dodo.provider.ts b/apps/core/src/modules/membership/providers/dodo.provider.ts index 13cd4be34bc..41e43a6ee57 100644 --- a/apps/core/src/modules/membership/providers/dodo.provider.ts +++ b/apps/core/src/modules/membership/providers/dodo.provider.ts @@ -68,6 +68,16 @@ const planFromInterval = ( return undefined } +const planFromEvent = ( + event: DodoSubscriptionEvent, +): MembershipPlan | undefined => { + const metadataPlan = event.data.metadata?.plan + if (metadataPlan === 'monthly' || metadataPlan === 'yearly') { + return metadataPlan + } + return planFromInterval(event.data.payment_frequency_interval) +} + @Injectable() export class DodoProvider implements PaymentProviderAdapter { private client: DodoPayments | null = null @@ -122,7 +132,7 @@ export class DodoProvider implements PaymentProviderAdapter { const session = await client.checkoutSessions.create({ product_cart: [{ product_id: productId, quantity: 1 }], - metadata: { readerId: input.reader.id }, + metadata: { readerId: input.reader.id, plan: input.plan }, customer: input.reader.email ? { email: input.reader.email, name: input.reader.name ?? undefined } : undefined, @@ -222,7 +232,7 @@ export class DodoProvider implements PaymentProviderAdapter { type, customerId: event.data.customer.customer_id, subscriptionId: event.data.subscription_id, - plan: planFromInterval(event.data.payment_frequency_interval), + plan: planFromEvent(event), currentPeriodEnd: new Date(event.data.next_billing_date), readerId, } diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index 6f8f3810f65..1577f99de67 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -86,13 +86,12 @@ export class PostController { private async applyPaywall( doc: Record, - metaBuilder: PostMetaBuilder, isOwner: boolean, readerId?: string, - ) { - if (!doc.isPremium) return + ): Promise<{ locked: boolean; previewBlocks?: number } | null> { + if (!doc.isPremium) return null - if (!(await this.entitlementService.isMembershipPurchasable())) return + if (!(await this.entitlementService.isMembershipPurchasable())) return null const isEntitled = isOwner || @@ -101,12 +100,11 @@ export class PostController { : false) if (isEntitled) { - metaBuilder.paywall({ locked: false }) - return + return { locked: false } } const previewBlocks = this.applyPaywallTeaser(doc) - metaBuilder.paywall({ locked: true, previewBlocks }) + return { locked: true, previewBlocks } } private applyPaywallTeaser(doc: Record): number { @@ -314,6 +312,12 @@ export class PostController { ) } + const paywall = await this.applyPaywall( + doc as Record, + !!isAuthenticated, + readerId, + ) + const { enrichments, ...docData } = await this.enrichmentService.attachEnrichments(doc) @@ -344,12 +348,7 @@ export class PostController { if (skills.length > 0) metaBuilder.skills(skills) - await this.applyPaywall( - docData as Record, - metaBuilder, - !!isAuthenticated, - readerId, - ) + if (paywall) metaBuilder.paywall(paywall) return withMeta(docData, metaBuilder.build()) } @@ -442,6 +441,11 @@ export class PostController { : relatedList const { related: _related, ...postEntity } = postDocument + const paywall = await this.applyPaywall( + postEntity as Record, + !!isAuthenticated, + readerId, + ) const { enrichments, ...postData } = await this.enrichmentService.attachEnrichments(postEntity) @@ -461,7 +465,7 @@ export class PostController { .insights({ hasInLocale: hasInsightsInLocale }) .enrichments(enrichments as Record) - if (summaryDoc) { + if (summaryDoc && !paywall?.locked) { metaBuilder.summary({ id: summaryDoc.id, text: summaryDoc.summary, @@ -485,12 +489,7 @@ export class PostController { if (skills.length > 0) metaBuilder.skills(skills) - await this.applyPaywall( - postData as Record, - metaBuilder, - !!isAuthenticated, - readerId, - ) + if (paywall) metaBuilder.paywall(paywall) return withMeta(postData, metaBuilder.build()) } diff --git a/apps/core/src/modules/search/search-document.util.ts b/apps/core/src/modules/search/search-document.util.ts index 2454468fb5b..10eb4e1dabc 100644 --- a/apps/core/src/modules/search/search-document.util.ts +++ b/apps/core/src/modules/search/search-document.util.ts @@ -185,13 +185,22 @@ export function computeSourceHash(parts: { /** * Source hash for a translation row. The translation table already stores a - * `hash` of its source snapshot — wrap it as an explicit accessor so callers - * don't conflate translation hash with the article's content hash. + * `hash` of its source snapshot; the paywall projection also shapes the + * indexed body, so premium state and preview-block config must be folded in + * or incremental rebuilds skip translated rows after a premium toggle. */ -export function computeTranslationSourceHash(translation: { - hash: string -}): string { - return translation.hash +export function computeTranslationSourceHash( + translation: { hash: string }, + article?: { + isPremium?: boolean | null + meta?: { paywall?: { previewBlocks?: unknown } } | null + }, +): string { + if (!article?.isPremium) return translation.hash + const previewBlocks = article.meta?.paywall?.previewBlocks + return createHash('sha1') + .update(`${translation.hash}\npremium:${previewBlocks ?? ''}`) + .digest('hex') } export const SEARCH_DOCUMENT_DEFAULT_SOURCE_LANG = 'zh' diff --git a/apps/core/src/modules/search/search.service.ts b/apps/core/src/modules/search/search.service.ts index 30a62c3db47..b09bf05a220 100644 --- a/apps/core/src/modules/search/search.service.ts +++ b/apps/core/src/modules/search/search.service.ts @@ -442,6 +442,16 @@ export class SearchService { sourceLang, ) as any, ) + + // Translated documents embed the article's paywall projection, so an + // article update (e.g. premium toggle) must refresh them too. + const translations = await this.aiTranslationRepository.listByRefId(id) + for (const t of translations) { + if (t.refType !== refType) continue + await this.searchRepository.upsert( + this.buildTranslationSearchDocument(refType, sourceDocument, t) as any, + ) + } } private async upsertTranslationSearchDocument(translation: AiTranslationRow) { @@ -489,7 +499,7 @@ export class SearchService { hasPassword: article.hasPassword, createdAt: article.createdAt ?? new Date(), modifiedAt: article.modifiedAt ?? null, - sourceHash: computeTranslationSourceHash(translation), + sourceHash: computeTranslationSourceHash(translation, article), } return buildSearchDocument( refType, diff --git a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts index 93b006af481..229596bf078 100644 --- a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts +++ b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts @@ -326,19 +326,37 @@ export class VisitorEventDispatchService implements OnModuleInit { // --- Translation events --- @OnVisitorEvent(BusinessEvents.TRANSLATION_CREATE) - onTranslationCreate(data: any) { + async onTranslationCreate(data: any) { if (!data.refId) return - this.webGateway.broadcast(BusinessEvents.TRANSLATION_CREATE, data, { - rooms: [buildArticleRoomName(data.refId)], - }) + this.webGateway.broadcast( + BusinessEvents.TRANSLATION_CREATE, + await this.toPublicTranslationPayload(data), + { + rooms: [buildArticleRoomName(data.refId)], + }, + ) } @OnVisitorEvent(BusinessEvents.TRANSLATION_UPDATE) - onTranslationUpdate(data: any) { + async onTranslationUpdate(data: any) { if (!data.refId) return - this.webGateway.broadcast(BusinessEvents.TRANSLATION_UPDATE, data, { - rooms: [buildArticleRoomName(data.refId)], - }) + this.webGateway.broadcast( + BusinessEvents.TRANSLATION_UPDATE, + await this.toPublicTranslationPayload(data), + { + rooms: [buildArticleRoomName(data.refId)], + }, + ) + } + + private async toPublicTranslationPayload(data: any) { + const isPremium = await this.enricher.isPremiumPost( + data.refType, + data.refId, + ) + if (!isPremium) return data + const { text: _text, summary: _summary, ...rest } = data + return rest } // --- Helpers --- diff --git a/apps/core/src/processors/helper/helper.event-payload.service.ts b/apps/core/src/processors/helper/helper.event-payload.service.ts index 49d95ab8bf6..4f06d3e7f54 100644 --- a/apps/core/src/processors/helper/helper.event-payload.service.ts +++ b/apps/core/src/processors/helper/helper.event-payload.service.ts @@ -74,4 +74,10 @@ export class EventPayloadEnricherService { } } } + + async isPremiumPost(refType: string, refId: string): Promise { + if (refType !== 'post') return false + const post = await this.postService.findById(refId) + return !!post?.isPremium + } } diff --git a/apps/core/test/src/modules/membership/dodo.provider.spec.ts b/apps/core/test/src/modules/membership/dodo.provider.spec.ts index 69505ff0c06..947e0f7a62c 100644 --- a/apps/core/test/src/modules/membership/dodo.provider.spec.ts +++ b/apps/core/test/src/modules/membership/dodo.provider.spec.ts @@ -60,7 +60,7 @@ describe('DodoProvider', () => { expect(checkoutCreateMock).toHaveBeenCalledWith( expect.objectContaining({ product_cart: [{ product_id: 'prod_monthly', quantity: 1 }], - metadata: { readerId: 'reader-1' }, + metadata: { readerId: 'reader-1', plan: 'monthly' }, }), ) }) @@ -276,6 +276,25 @@ describe('DodoProvider', () => { expect(event.plan).toBe('yearly') }) + it('prefers the checkout metadata plan when the interval field is absent', async () => { + verifyMock.mockReturnValue({ + type: 'subscription.active', + business_id: 'biz_1', + timestamp: '2026-01-01T00:00:00Z', + data: { + subscription_id: 'sub_1', + customer: { customer_id: 'cus_1' }, + metadata: { readerId: 'reader-1', plan: 'yearly' }, + next_billing_date: '2026-02-01T00:00:00Z', + }, + }) + + const provider = new DodoProvider(configsService as any) + const event = await provider.verifyAndParseWebhook('{}', headers) + + expect(event.plan).toBe('yearly') + }) + it('throws MEMBERSHIP_PROVIDER_NOT_CONFIGURED when dodoWebhookKey is empty', async () => { configsService.get.mockResolvedValue({ enabled: true, diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index f96babd9fe5..3ad59f650a8 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -35,6 +35,8 @@ const readerId = snowflake.nextId() const otherReaderId = snowflake.nextId() const liveSubReaderId = snowflake.nextId() const expiredReaderId = snowflake.nextId() +const checkoutActiveReaderId = snowflake.nextId() +const checkoutExpiredReaderId = snowflake.nextId() const readerUser = { id: readerId, @@ -50,6 +52,20 @@ const expiredReaderUser = { role: 'reader' as const, } +const checkoutActiveReaderUser = { + id: checkoutActiveReaderId, + email: 'checkout-active@example.com', + name: 'Reader Checkout Active', + role: 'reader' as const, +} + +const checkoutExpiredReaderUser = { + id: checkoutExpiredReaderId, + email: 'checkout-expired@example.com', + name: 'Reader Checkout Expired', + role: 'reader' as const, +} + const membershipConfig: { enabled: boolean provider: string | undefined @@ -81,6 +97,18 @@ const authServiceMock = { if (header === 'expired-reader') { return { user: expiredReaderUser, session: { token: 'expired-token' } } } + if (header === 'checkout-active') { + return { + user: checkoutActiveReaderUser, + session: { token: 'checkout-active-token' }, + } + } + if (header === 'checkout-expired') { + return { + user: checkoutExpiredReaderUser, + session: { token: 'checkout-expired-token' }, + } + } return null }), } @@ -156,6 +184,8 @@ beforeAll(async () => { { id: otherReaderId, name: 'Reader Two', role: 'reader' }, { id: liveSubReaderId, name: 'Reader Three', role: 'reader' }, { id: expiredReaderId, name: 'Reader Expired', role: 'reader' }, + { id: checkoutActiveReaderId, name: 'Checkout Active', role: 'reader' }, + { id: checkoutExpiredReaderId, name: 'Checkout Expired', role: 'reader' }, ]) }, 120_000) @@ -242,6 +272,61 @@ describe('MembershipController (e2e)', () => { ) }) + it('refuses checkout when the reader already has a live membership', async () => { + const membershipRepository = proxy.app.get(MembershipRepository) + await membershipRepository.create({ + readerId: checkoutActiveReaderId, + provider: 'dodo', + providerCustomerId: 'cus_checkout_active', + providerSubscriptionId: 'sub_checkout_active', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }) + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'checkout-active', + 'content-type': 'application/json', + }, + payload: { plan: 'monthly' }, + }) + + expect(res.statusCode).toBe(409) + expect(res.json()).toMatchObject({ + error: { code: 'MEMBERSHIP_ALREADY_ACTIVE' }, + }) + expect(createCheckoutMock).not.toHaveBeenCalled() + }) + + it('allows checkout again after the stored membership has expired', async () => { + const membershipRepository = proxy.app.get(MembershipRepository) + await membershipRepository.create({ + readerId: checkoutExpiredReaderId, + provider: 'dodo', + providerCustomerId: 'cus_checkout_expired', + providerSubscriptionId: 'sub_checkout_expired', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(Date.now() - 24 * 60 * 60 * 1000), + }) + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/checkout', + headers: { + 'x-test-reader': 'checkout-expired', + 'content-type': 'application/json', + }, + payload: { plan: 'monthly' }, + }) + + expect(res.statusCode).toBe(201) + expect(createCheckoutMock).toHaveBeenCalled() + }) + it('rejects anonymous callers with 401', async () => { const res = await proxy.app.inject({ method: 'POST', diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 0f23dfca44b..0b08a82db08 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -338,6 +338,23 @@ describe('MembershipService', () => { ).rejects.toThrow() }) + it('allows granting when an active provider subscription is past its period end', async () => { + const existing = createMembership({ + provider: 'dodo', + status: 'active', + currentPeriodEnd: new Date(now.getTime() - 1000), + }) + membershipRepository.findByReaderId.mockResolvedValue(existing) + membershipRepository.update.mockResolvedValue(existing) + + await service.grantManual('reader-1', { + plan: 'monthly', + expiresAt: new Date(now.getTime() + 1000), + }) + + expect(membershipRepository.update).toHaveBeenCalled() + }) + it('allows granting when the provider subscription has expired', async () => { const existing = createMembership({ provider: 'dodo', diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts index dd39fdb4377..6ff3d320b58 100644 --- a/apps/core/test/src/modules/membership/membership.types.spec.ts +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -42,6 +42,16 @@ describe('resolveMembershipAvailability', () => { resolveMembershipAvailability({ enabled: true, provider: 'dodo' }), ).toEqual({ enabled: false, plans: [] }) }) + + it('is disabled when the provider has no registered adapter', () => { + expect( + resolveMembershipAvailability({ + enabled: true, + provider: 'stripe', + monthlyProductId: 'm', + }), + ).toEqual({ enabled: false, plans: [] }) + }) }) describe('resolveMembershipReturnUrl', () => { diff --git a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts index 17af0a68ad0..8786c88ed23 100644 --- a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts @@ -400,6 +400,57 @@ describe('Post paywall enforcement (e2e)', () => { expect(body.meta.paywall).toBeUndefined() }) + it('omits the ai summary and builds enrichments from the teaser when locked', async () => { + currentPost = { ...premiumPostFixture } + const summaryService = proxy.app.get(AiSummaryService) as any + const enrichmentService = proxy.app.get(EnrichmentService) as any + summaryService.getSummaryForPublicMeta.mockResolvedValueOnce({ + id: 'summary-1', + summary: 'summary generated from the full article', + lang: 'zh', + createdAt: new Date('2024-01-02'), + }) + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.category.slug}/${premiumPostFixture.slug}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(true) + expect(body.meta.summary).toBeUndefined() + + const enrichedDoc = + enrichmentService.attachEnrichments.mock.calls.at(-1)![0] + expect(JSON.parse(enrichedDoc.content).root.children).toHaveLength(3) + }) + + it('keeps the ai summary for an entitled member', async () => { + currentPost = { ...premiumPostFixture } + const summaryService = proxy.app.get(AiSummaryService) as any + summaryService.getSummaryForPublicMeta.mockResolvedValueOnce({ + id: 'summary-1', + summary: 'summary generated from the full article', + lang: 'zh', + createdAt: new Date('2024-01-02'), + }) + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.category.slug}/${premiumPostFixture.slug}`, + headers: headerFor(activeMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(false) + expect(body.meta.summary.text).toBe( + 'summary generated from the full article', + ) + }) + it('getById applies the same gate for a non-member reader', async () => { currentPost = { ...premiumPostFixture } diff --git a/apps/core/test/src/modules/search/search-document.util.spec.ts b/apps/core/test/src/modules/search/search-document.util.spec.ts index d5d9ecab481..e5c00f010f4 100644 --- a/apps/core/test/src/modules/search/search-document.util.spec.ts +++ b/apps/core/test/src/modules/search/search-document.util.spec.ts @@ -76,6 +76,24 @@ describe('search-document.util', () => { expect(computeTranslationSourceHash({ hash: 'abc' })).toBe('abc') }) + it('changes the translation source hash when premium state or preview config changes', () => { + const plain = computeTranslationSourceHash({ hash: 'abc' }) + const premium = computeTranslationSourceHash( + { hash: 'abc' }, + { isPremium: true }, + ) + const premiumWithConfig = computeTranslationSourceHash( + { hash: 'abc' }, + { isPremium: true, meta: { paywall: { previewBlocks: 2 } } }, + ) + + expect(premium).not.toBe(plain) + expect(premiumWithConfig).not.toBe(premium) + expect( + computeTranslationSourceHash({ hash: 'abc' }, { isPremium: false }), + ).toBe(plain) + }) + it('builds english documents in their declared lang', () => { const document = buildSearchDocument( 'post', diff --git a/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts b/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts index 73a6beb8a72..d8806e699a2 100644 --- a/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts +++ b/apps/core/test/src/processors/gateway/web/visitor-event-dispatch.service.spec.ts @@ -1,7 +1,7 @@ import { BusinessEvents } from '~/constants/business-event.constant' import { VisitorEventDispatchService } from '~/processors/gateway/web/visitor-event-dispatch.service' -const createService = () => { +const createService = (enricher: Record = {}) => { const broadcasts: Array<{ data: any event: BusinessEvents @@ -64,7 +64,7 @@ const createService = () => { } const service = new VisitorEventDispatchService( - {} as any, + enricher as any, webGateway as any, {} as any, translationService as any, @@ -216,4 +216,41 @@ describe('VisitorEventDispatchService premium paywall', () => { expect(jaPayload.text).not.toContain('secret') expect(JSON.parse(jaPayload.content).root.children).toHaveLength(1) }) + + it('strips translated text and summary when broadcasting for a premium post', async () => { + const { broadcasts, service } = createService({ + isPremiumPost: vi.fn(async () => true), + }) + + await (service as any).onTranslationUpdate({ + id: 'tr-1', + refId: 'post-1', + refType: 'post', + lang: 'ja', + text: 'secret paywalled translated body', + summary: 'summary of the full article', + title: '日本語タイトル', + }) + + expect(broadcasts).toHaveLength(1) + expect(broadcasts[0].data.text).toBeUndefined() + expect(broadcasts[0].data.summary).toBeUndefined() + expect(broadcasts[0].data.title).toBe('日本語タイトル') + }) + + it('keeps translated text for non-premium posts', async () => { + const { broadcasts, service } = createService({ + isPremiumPost: vi.fn(async () => false), + }) + + await (service as any).onTranslationUpdate({ + id: 'tr-2', + refId: 'post-2', + refType: 'post', + lang: 'ja', + text: 'plain translated body', + }) + + expect(broadcasts[0].data.text).toBe('plain translated body') + }) }) From 3922afcfee0a2b47d1f14153382db6d36af21fad Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 17:00:14 +0800 Subject: [PATCH 36/37] fix: address membership review findings --- .../readers/utils/membership-status.test.ts | 60 ++++++++++ .../readers/utils/membership-status.ts | 3 +- .../common/interceptors/cache.interceptor.ts | 21 +++- .../membership/membership.controller.ts | 9 +- .../modules/membership/membership.service.ts | 68 +++++++++--- .../modules/membership/membership.types.ts | 5 + .../membership/providers/dodo.provider.ts | 23 ++-- .../providers/provider.interface.ts | 8 +- .../src/modules/reader/reader.repository.ts | 5 +- .../interceptors/cache.interceptor.spec.ts | 49 +++++++++ .../modules/membership/dodo.provider.spec.ts | 36 +++--- .../membership/entitlement.service.spec.ts | 6 + .../membership.controller.e2e-spec.ts | 45 ++++++-- .../membership/membership.service.spec.ts | 104 +++++++++++++++--- .../membership/membership.types.spec.ts | 27 +++++ .../src/modules/post/post-paywall.e2e-spec.ts | 2 + .../reader/reader.repository.pg.e2e.spec.ts | 2 +- .../__tests__/controllers/membership.test.ts | 15 +++ packages/api-client/controllers/membership.ts | 2 +- 19 files changed, 419 insertions(+), 71 deletions(-) create mode 100644 apps/admin/src/features/readers/utils/membership-status.test.ts diff --git a/apps/admin/src/features/readers/utils/membership-status.test.ts b/apps/admin/src/features/readers/utils/membership-status.test.ts new file mode 100644 index 00000000000..267c78e5e71 --- /dev/null +++ b/apps/admin/src/features/readers/utils/membership-status.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ReaderMembership } from '~/api/readers' + +import { hasLiveProviderManagedMembership } from './membership-status' + +const now = new Date('2026-07-19T00:00:00.000Z') + +const createMembership = ( + overrides: Partial = {}, +): ReaderMembership => ({ + status: 'active', + plan: 'monthly', + provider: 'dodo', + currentPeriodEnd: new Date(now.getTime() + 60_000).toISOString(), + ...overrides, +}) + +describe('hasLiveProviderManagedMembership', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('treats active and on-hold provider memberships as live only before period end', () => { + expect(hasLiveProviderManagedMembership(createMembership())).toBe(true) + expect( + hasLiveProviderManagedMembership(createMembership({ status: 'on_hold' })), + ).toBe(true) + + const lapsed = new Date(now.getTime() - 1).toISOString() + expect( + hasLiveProviderManagedMembership( + createMembership({ currentPeriodEnd: lapsed }), + ), + ).toBe(false) + expect( + hasLiveProviderManagedMembership( + createMembership({ status: 'on_hold', currentPeriodEnd: lapsed }), + ), + ).toBe(false) + }) + + it('does not treat manual or cancelled memberships as provider-managed live access', () => { + expect( + hasLiveProviderManagedMembership( + createMembership({ provider: 'manual' }), + ), + ).toBe(false) + expect( + hasLiveProviderManagedMembership( + createMembership({ status: 'cancelled' }), + ), + ).toBe(false) + }) +}) diff --git a/apps/admin/src/features/readers/utils/membership-status.ts b/apps/admin/src/features/readers/utils/membership-status.ts index 434d11ce5a0..936a5a0e635 100644 --- a/apps/admin/src/features/readers/utils/membership-status.ts +++ b/apps/admin/src/features/readers/utils/membership-status.ts @@ -51,8 +51,7 @@ export function hasLiveProviderManagedMembership( membership: ReaderMembership | null | undefined, ): boolean { if (!membership || membership.provider === 'manual') return false - if (membership.status === 'active') return true - if (membership.status === 'on_hold') { + if (membership.status === 'active' || membership.status === 'on_hold') { return new Date(membership.currentPeriodEnd).getTime() > Date.now() } return false diff --git a/apps/core/src/common/interceptors/cache.interceptor.ts b/apps/core/src/common/interceptors/cache.interceptor.ts index e34935e7717..b3e9521f551 100644 --- a/apps/core/src/common/interceptors/cache.interceptor.ts +++ b/apps/core/src/common/interceptors/cache.interceptor.ts @@ -83,16 +83,22 @@ export class HttpCacheInterceptor implements NestInterceptor { ), ]) - if (value) { + const hasSafeCachedValue = !!value && !this.isUnsafeForSharedCache(value) + + if (hasSafeCachedValue) { this.logger.debug(`hit cache:${key}`) this.setCacheHeader(res, ttl) } - return value + return hasSafeCachedValue ? of(value) : call$.pipe( tap((response) => { - if (response && !this.isEntitledFullContent(response)) { + if (response && this.isUnsafeForSharedCache(response)) { + this.setPrivateCacheHeader(res) + return + } + if (response) { this.cacheManager.set(key, response, ttl * 1000) } this.setCacheHeader(res, ttl) @@ -104,8 +110,13 @@ export class HttpCacheInterceptor implements NestInterceptor { } } - private isEntitledFullContent(response: any): boolean { - return response?.meta?.paywall?.locked === false + private isUnsafeForSharedCache(response: any): boolean { + if (response?.meta?.paywall?.locked === false) return true + + return ( + response?.data?.isPremium === true && + response?.meta?.paywall?.locked !== true + ) } private setPrivateCacheHeader(res: FastifyReply) { diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index e107dccf83c..bb85f4dd9ab 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -93,11 +93,18 @@ export class MembershipController { const returnUrl = resolveMembershipReturnUrl(body.returnPath, webUrl) const adapter = this.providers.resolve(membershipConfig.provider) - return adapter.createCheckout({ + const checkout = await adapter.createCheckout({ reader: { id: user.id, email: user.email, name: user.name }, plan: body.plan, returnUrl, }) + if (existing) { + await this.membershipService.prepareForCheckout( + existing, + membershipConfig.provider, + ) + } + return checkout } @Get('/plans') diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index def17ed3a99..18060aebe04 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -5,8 +5,15 @@ import type { EntityId } from '~/shared/id/entity-id' import { BillingWebhookEventRepository } from './billing-webhook-event.repository' import { MembershipRepository } from './membership.repository' -import type { MembershipPlan, MembershipRow } from './membership.types' -import type { NormalizedBillingEvent } from './providers/provider.interface' +import type { + MembershipPlan, + MembershipProvider, + MembershipRow, +} from './membership.types' +import type { + NormalizedBillingEvent, + VerifiedBillingEvent, +} from './providers/provider.interface' const isLiveProviderSubscription = (row: MembershipRow): boolean => { if (row.provider === 'manual') return false @@ -32,13 +39,14 @@ export class MembershipService { } async applyEvent( - event: NormalizedBillingEvent, + verifiedEvent: VerifiedBillingEvent, ): Promise<{ applied: boolean }> { + const { event, rawPayload, rawType } = verifiedEvent const webhookEventRow = await this.billingWebhookEventRepository.create({ provider: event.provider, eventId: event.eventId, - type: event.type, - payload: event, + type: rawType, + payload: rawPayload, }) if (!webhookEventRow) { @@ -49,30 +57,43 @@ export class MembershipService { ) if (!existingRow || existingRow.processedAt) return { applied: false } - await this.applyMembershipState(event) + const applied = await this.applyMembershipState(event) await this.billingWebhookEventRepository.markProcessed( existingRow.id, new Date(), ) - return { applied: true } + return { applied } } - await this.applyMembershipState(event) + const applied = await this.applyMembershipState(event) await this.billingWebhookEventRepository.markProcessed( webhookEventRow.id, new Date(), ) - return { applied: true } + return { applied } } private async applyMembershipState( event: NormalizedBillingEvent, - ): Promise { - const existing = - (await this.membershipRepository.findByProviderSubscriptionId( - event.subscriptionId, - )) ?? (await this.membershipRepository.findByReaderId(event.readerId)) + ): Promise { + let existing = await this.membershipRepository.findByProviderSubscriptionId( + event.subscriptionId, + ) + + if (!existing) { + const byReader = await this.membershipRepository.findByReaderId( + event.readerId, + ) + if (byReader) { + const canBindInitialSubscription = + byReader.provider === event.provider && + byReader.providerSubscriptionId === null && + event.type === 'activated' + if (!canBindInitialSubscription) return false + existing = byReader + } + } if (event.type === 'plan_changed') { if (existing && event.plan) { @@ -80,7 +101,7 @@ export class MembershipService { plan: event.plan, }) } - return + return true } const status = @@ -99,7 +120,7 @@ export class MembershipService { status, currentPeriodEnd: event.currentPeriodEnd, }) - return + return true } await this.membershipRepository.create({ @@ -111,6 +132,21 @@ export class MembershipService { status, currentPeriodEnd: event.currentPeriodEnd, }) + return true + } + + async prepareForCheckout( + membership: MembershipRow, + provider: MembershipProvider, + ): Promise { + if (isLiveProviderSubscription(membership)) return + + await this.membershipRepository.update(membership.id, { + provider, + providerCustomerId: null, + providerSubscriptionId: null, + status: 'expired', + }) } async grantManual( diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 3b9d033870a..f0b8cdd32eb 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -43,14 +43,19 @@ export function resolveMembershipAvailability(config: { provider?: string monthlyProductId?: string yearlyProductId?: string + dodoApiKey?: string + dodoWebhookKey?: string }): MembershipAvailability { const plans: MembershipPlan[] = [] if (config.monthlyProductId) plans.push('monthly') if (config.yearlyProductId) plans.push('yearly') + const hasProviderCredentials = + config.provider === 'dodo' && !!config.dodoApiKey && !!config.dodoWebhookKey const enabled = !!config.enabled && !!config.provider && REGISTERED_PAYMENT_PROVIDERS.includes(config.provider) && + hasProviderCredentials && plans.length > 0 return { enabled, plans: enabled ? plans : [] } } diff --git a/apps/core/src/modules/membership/providers/dodo.provider.ts b/apps/core/src/modules/membership/providers/dodo.provider.ts index 41e43a6ee57..68e04d9ef8b 100644 --- a/apps/core/src/modules/membership/providers/dodo.provider.ts +++ b/apps/core/src/modules/membership/providers/dodo.provider.ts @@ -10,6 +10,7 @@ import type { NormalizedBillingEvent, NormalizedPlanPricing, PaymentProviderAdapter, + VerifiedBillingEvent, } from './provider.interface' const PRICING_TTL_MS = 10 * 60 * 1000 @@ -199,7 +200,7 @@ export class DodoProvider implements PaymentProviderAdapter { async verifyAndParseWebhook( rawBody: Buffer | string, headers: Record, - ): Promise { + ): Promise { const membershipConfig = await this.configsService.get('membership') if (!membershipConfig.dodoWebhookKey) { throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) @@ -227,14 +228,18 @@ export class DodoProvider implements PaymentProviderAdapter { } return { - eventId: headers['webhook-id'], - provider: 'dodo', - type, - customerId: event.data.customer.customer_id, - subscriptionId: event.data.subscription_id, - plan: planFromEvent(event), - currentPeriodEnd: new Date(event.data.next_billing_date), - readerId, + event: { + eventId: headers['webhook-id'], + provider: 'dodo', + type, + customerId: event.data.customer.customer_id, + subscriptionId: event.data.subscription_id, + plan: planFromEvent(event), + currentPeriodEnd: new Date(event.data.next_billing_date), + readerId, + }, + rawType: event.type, + rawPayload: event, } } } diff --git a/apps/core/src/modules/membership/providers/provider.interface.ts b/apps/core/src/modules/membership/providers/provider.interface.ts index d733977c059..c1bfb6f5f08 100644 --- a/apps/core/src/modules/membership/providers/provider.interface.ts +++ b/apps/core/src/modules/membership/providers/provider.interface.ts @@ -11,6 +11,12 @@ export interface NormalizedBillingEvent { readerId: string } +export interface VerifiedBillingEvent { + event: NormalizedBillingEvent + rawType: string + rawPayload: unknown +} + export interface NormalizedPlanPricing { amount: number currency: string @@ -30,7 +36,7 @@ export interface PaymentProviderAdapter { verifyAndParseWebhook: ( rawBody: Buffer | string, headers: Record, - ) => Promise + ) => Promise getPortalUrl?: (customerId: string) => Promise } diff --git a/apps/core/src/modules/reader/reader.repository.ts b/apps/core/src/modules/reader/reader.repository.ts index a7d13adebd5..c3f0648aad3 100644 --- a/apps/core/src/modules/reader/reader.repository.ts +++ b/apps/core/src/modules/reader/reader.repository.ts @@ -224,8 +224,11 @@ export class ReaderRepository extends BaseRepository { case 'expired': { conditions.push( or( - lte(memberships.currentPeriodEnd, now), eq(memberships.status, 'expired'), + and( + inArray(memberships.status, ['active', 'on_hold']), + lte(memberships.currentPeriodEnd, now), + ), )!, ) break diff --git a/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts b/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts index e268fa312a0..62b286b4520 100644 --- a/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts +++ b/apps/core/test/src/common/interceptors/cache.interceptor.spec.ts @@ -193,6 +193,55 @@ describe('HttpCacheInterceptor entitlement-keyed caching', () => { expect(store.size).toBe(0) }) + it('keeps an unguarded premium response out of shared caches when membership is unavailable', async () => { + const store = new Map() + const { interceptor } = createInterceptor(store) + const reply = createReply() + const premiumResponse = { + data: { isPremium: true, title: 'Full premium content' }, + meta: {}, + } + + const result = await firstValueFrom( + (await interceptor.intercept( + createContext(baseRequest(), reply), + createHandler(premiumResponse), + )) as any, + ) + + expect(result).toEqual(premiumResponse) + expect(store.size).toBe(0) + expect(reply.headers['cache-control']).toContain('private') + expect(reply.headers['cdn-cache-control']).toContain('private') + }) + + it('does not replay an unguarded premium response left by an earlier configuration', async () => { + const stalePremiumResponse = { + data: { isPremium: true, title: 'Full premium content' }, + meta: {}, + } + const store = new Map([ + ['mx-api-cache:/posts/paywalled-post', stalePremiumResponse], + ]) + const { interceptor } = createInterceptor(store) + const teaserResponse = { + data: { isPremium: true, title: 'Teaser' }, + meta: { paywall: { locked: true } }, + } + const handler = vi.fn(() => of(teaserResponse)) + + const result = await firstValueFrom( + (await interceptor.intercept( + createContext(baseRequest(), createReply()), + { handle: handler }, + )) as any, + ) + + expect(handler).toHaveBeenCalled() + expect(result).toEqual(teaserResponse) + expect([...store.values()]).toContainEqual(teaserResponse) + }) + it('sends private cache-control headers for any reader-identified request regardless of route', async () => { const store = new Map() const { interceptor, cacheManager } = createInterceptor(store) diff --git a/apps/core/test/src/modules/membership/dodo.provider.spec.ts b/apps/core/test/src/modules/membership/dodo.provider.spec.ts index 947e0f7a62c..a072a9036cd 100644 --- a/apps/core/test/src/modules/membership/dodo.provider.spec.ts +++ b/apps/core/test/src/modules/membership/dodo.provider.spec.ts @@ -221,7 +221,7 @@ describe('DodoProvider', () => { } it('maps subscription.active to activated', async () => { - verifyMock.mockReturnValue({ + const rawEvent = { type: 'subscription.active', business_id: 'biz_1', timestamp: '2026-01-01T00:00:00Z', @@ -232,20 +232,25 @@ describe('DodoProvider', () => { next_billing_date: '2026-02-01T00:00:00Z', payment_frequency_interval: 'Month', }, - }) + } + verifyMock.mockReturnValue(rawEvent) const provider = new DodoProvider(configsService as any) const event = await provider.verifyAndParseWebhook('{}', headers) expect(event).toEqual({ - eventId: 'evt_1', - provider: 'dodo', - type: 'activated', - customerId: 'cus_1', - subscriptionId: 'sub_1', - plan: 'monthly', - currentPeriodEnd: new Date('2026-02-01T00:00:00Z'), - readerId: 'reader-1', + event: { + eventId: 'evt_1', + provider: 'dodo', + type: 'activated', + customerId: 'cus_1', + subscriptionId: 'sub_1', + plan: 'monthly', + currentPeriodEnd: new Date('2026-02-01T00:00:00Z'), + readerId: 'reader-1', + }, + rawType: 'subscription.active', + rawPayload: rawEvent, }) }) @@ -272,8 +277,13 @@ describe('DodoProvider', () => { const provider = new DodoProvider(configsService as any) const event = await provider.verifyAndParseWebhook('{}', headers) - expect(event.type).toBe(expectedType) - expect(event.plan).toBe('yearly') + expect(event.event.type).toBe(expectedType) + expect(event.event.plan).toBe('yearly') + expect(event.rawType).toBe(dodoType) + expect(event.rawPayload).toMatchObject({ + type: dodoType, + timestamp: '2026-01-01T00:00:00Z', + }) }) it('prefers the checkout metadata plan when the interval field is absent', async () => { @@ -292,7 +302,7 @@ describe('DodoProvider', () => { const provider = new DodoProvider(configsService as any) const event = await provider.verifyAndParseWebhook('{}', headers) - expect(event.plan).toBe('yearly') + expect(event.event.plan).toBe('yearly') }) it('throws MEMBERSHIP_PROVIDER_NOT_CONFIGURED when dodoWebhookKey is empty', async () => { diff --git a/apps/core/test/src/modules/membership/entitlement.service.spec.ts b/apps/core/test/src/modules/membership/entitlement.service.spec.ts index 4aaacee699f..1f50991efab 100644 --- a/apps/core/test/src/modules/membership/entitlement.service.spec.ts +++ b/apps/core/test/src/modules/membership/entitlement.service.spec.ts @@ -27,6 +27,8 @@ const createService = ( provider: 'dodo', monthlyProductId: 'prod_monthly', yearlyProductId: 'prod_yearly', + dodoApiKey: 'api-key', + dodoWebhookKey: 'webhook-key', }, ) => { const membershipRepository = createPgRepositoryMock() @@ -166,6 +168,8 @@ describe('EntitlementService.getAvailability', () => { enabled: true, provider: 'dodo', monthlyProductId: 'prod_monthly', + dodoApiKey: 'api-key', + dodoWebhookKey: 'webhook-key', }) expect(await service.getAvailability()).toEqual({ enabled: true, @@ -178,6 +182,8 @@ describe('EntitlementService.getAvailability', () => { enabled: false, provider: 'dodo', monthlyProductId: 'prod_monthly', + dodoApiKey: 'api-key', + dodoWebhookKey: 'webhook-key', }) expect(await service.getAvailability()).toEqual({ enabled: false, diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 3ad59f650a8..b679c51e4a5 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -71,11 +71,15 @@ const membershipConfig: { provider: string | undefined monthlyProductId: string yearlyProductId: string + dodoApiKey: string + dodoWebhookKey: string } = { enabled: true, provider: 'dodo', monthlyProductId: 'prod_monthly', yearlyProductId: 'prod_yearly', + dodoApiKey: 'api-key', + dodoWebhookKey: 'webhook-key', } const urlConfig: { webUrl: string | undefined } = { webUrl: undefined } @@ -126,14 +130,18 @@ const verifyAndParseWebhookMock = vi.fn( } const body = JSON.parse(rawBody.toString('utf8')) return { - eventId: body.eventId, - provider: 'dodo', - type: body.type, - customerId: body.customerId, - subscriptionId: body.subscriptionId, - plan: body.plan, - currentPeriodEnd: new Date(body.currentPeriodEnd), - readerId: body.readerId, + event: { + eventId: body.eventId, + provider: 'dodo', + type: body.type, + customerId: body.customerId, + subscriptionId: body.subscriptionId, + plan: body.plan, + currentPeriodEnd: new Date(body.currentPeriodEnd), + readerId: body.readerId, + }, + rawType: body.providerEventType ?? body.type, + rawPayload: body, } }, ) @@ -325,6 +333,10 @@ describe('MembershipController (e2e)', () => { expect(res.statusCode).toBe(201) expect(createCheckoutMock).toHaveBeenCalled() + expect( + (await membershipRepository.findByReaderId(checkoutExpiredReaderId)) + ?.providerSubscriptionId, + ).toBeNull() }) it('rejects anonymous callers with 401', async () => { @@ -491,6 +503,8 @@ describe('MembershipController (e2e)', () => { const payload = { eventId: 'evt_checkout_1', type: 'activated', + providerEventType: 'subscription.active', + timestamp: '2026-07-19T00:00:00.000Z', customerId: 'cus_1', subscriptionId: 'sub_1', plan: 'monthly', @@ -520,6 +534,21 @@ describe('MembershipController (e2e)', () => { data: { ok: true, applied: false }, }) + const webhookEventRepository = proxy.app.get( + BillingWebhookEventRepository, + ) + const auditRow = await webhookEventRepository.findByProviderAndEventId( + 'dodo', + payload.eventId, + ) + expect(auditRow).toMatchObject({ + type: 'subscription.active', + payload: expect.objectContaining({ + timestamp: '2026-07-19T00:00:00.000Z', + providerEventType: 'subscription.active', + }), + }) + const status = await proxy.app.inject({ method: 'GET', url: '/membership/status', diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 0b08a82db08..373f3ecfe8f 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -5,7 +5,10 @@ import type { BillingWebhookEventRepository } from '~/modules/membership/billing import type { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' import type { MembershipRow } from '~/modules/membership/membership.types' -import type { NormalizedBillingEvent } from '~/modules/membership/providers/provider.interface' +import type { + NormalizedBillingEvent, + VerifiedBillingEvent, +} from '~/modules/membership/providers/provider.interface' const createMembership = ( overrides: Partial = {}, @@ -25,16 +28,24 @@ const createMembership = ( const createEvent = ( overrides: Partial = {}, -): NormalizedBillingEvent => ({ - eventId: 'evt_1', - provider: 'dodo', - type: 'activated', - customerId: 'cus_1', - subscriptionId: 'sub_1', - plan: 'monthly', - currentPeriodEnd: new Date(now.getTime() + 1000 * 60 * 60), - readerId: 'reader-1', - ...overrides, +): VerifiedBillingEvent => ({ + event: { + eventId: 'evt_1', + provider: 'dodo', + type: 'activated', + customerId: 'cus_1', + subscriptionId: 'sub_1', + plan: 'monthly', + currentPeriodEnd: new Date(now.getTime() + 1000 * 60 * 60), + readerId: 'reader-1', + ...overrides, + }, + rawType: 'subscription.active', + rawPayload: { + type: 'subscription.active', + timestamp: '2026-07-19T00:00:00.000Z', + data: { subscription_id: overrides.subscriptionId ?? 'sub_1' }, + }, }) const createService = () => { @@ -154,8 +165,11 @@ describe('MembershipService', () => { expect(billingWebhookEventRepository.create).toHaveBeenCalledWith({ provider: 'dodo', eventId: 'evt_1', - type: 'activated', - payload: expect.objectContaining({ eventId: 'evt_1' }), + type: 'subscription.active', + payload: expect.objectContaining({ + type: 'subscription.active', + timestamp: '2026-07-19T00:00:00.000Z', + }), }) expect(membershipRepository.create).toHaveBeenCalled() expect(billingWebhookEventRepository.markProcessed).toHaveBeenCalledWith( @@ -253,6 +267,70 @@ describe('MembershipService', () => { expect(membershipRepository.update).not.toHaveBeenCalled() expect(membershipRepository.create).not.toHaveBeenCalled() }) + + it.each([ + { + name: 'a replacement provider subscription', + membership: createMembership({ + providerSubscriptionId: 'sub_current', + status: 'active', + }), + }, + { + name: 'a manual grant', + membership: createMembership({ + provider: 'manual', + providerCustomerId: null, + providerSubscriptionId: null, + }), + }, + ])( + 'rejects a stale cancellation when the reader now has $name', + async ({ membership }) => { + const { service, membershipRepository } = createService() + membershipRepository.findByReaderId.mockResolvedValue(membership) + + const result = await service.applyEvent( + createEvent({ + eventId: 'evt_old_cancel', + type: 'cancelled', + subscriptionId: 'sub_old', + }), + ) + + expect(result).toEqual({ applied: false }) + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(membershipRepository.create).not.toHaveBeenCalled() + }, + ) + + it('binds an activation to the provider row prepared for a replacement checkout', async () => { + const { service, membershipRepository } = createService() + const prepared = createMembership({ + providerSubscriptionId: null, + status: 'expired', + currentPeriodEnd: new Date(now.getTime() - 1000), + }) + membershipRepository.findByReaderId.mockResolvedValue(prepared) + + const newPeriodEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + const result = await service.applyEvent( + createEvent({ + subscriptionId: 'sub_replacement', + currentPeriodEnd: newPeriodEnd, + }), + ) + + expect(result).toEqual({ applied: true }) + expect(membershipRepository.update).toHaveBeenCalledWith( + prepared.id, + expect.objectContaining({ + providerSubscriptionId: 'sub_replacement', + status: 'active', + currentPeriodEnd: newPeriodEnd, + }), + ) + }) }) describe('manual grant', () => { diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts index 6ff3d320b58..a18d0b755e3 100644 --- a/apps/core/test/src/modules/membership/membership.types.spec.ts +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -5,6 +5,11 @@ import { resolveMembershipReturnUrl, } from '~/modules/membership/membership.types' +const dodoCredentials = { + dodoApiKey: 'api-key', + dodoWebhookKey: 'webhook-key', +} + describe('resolveMembershipAvailability', () => { it('is enabled with both plans when fully configured', () => { expect( @@ -13,6 +18,7 @@ describe('resolveMembershipAvailability', () => { provider: 'dodo', monthlyProductId: 'm', yearlyProductId: 'y', + ...dodoCredentials, }), ).toEqual({ enabled: true, plans: ['monthly', 'yearly'] }) }) @@ -23,6 +29,7 @@ describe('resolveMembershipAvailability', () => { enabled: true, provider: 'dodo', yearlyProductId: 'y', + ...dodoCredentials, }), ).toEqual({ enabled: true, plans: ['yearly'] }) }) @@ -33,6 +40,7 @@ describe('resolveMembershipAvailability', () => { enabled: false, provider: 'dodo', monthlyProductId: 'm', + ...dodoCredentials, }), ).toEqual({ enabled: false, plans: [] }) expect( @@ -52,6 +60,25 @@ describe('resolveMembershipAvailability', () => { }), ).toEqual({ enabled: false, plans: [] }) }) + + it('is disabled when a required Dodo credential is absent', () => { + expect( + resolveMembershipAvailability({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'm', + dodoWebhookKey: 'webhook-key', + }), + ).toEqual({ enabled: false, plans: [] }) + expect( + resolveMembershipAvailability({ + enabled: true, + provider: 'dodo', + monthlyProductId: 'm', + dodoApiKey: 'api-key', + }), + ).toEqual({ enabled: false, plans: [] }) + }) }) describe('resolveMembershipReturnUrl', () => { diff --git a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts index 8786c88ed23..411a7ff9028 100644 --- a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts @@ -149,6 +149,8 @@ const membershipConfig: Record = { provider: 'dodo', monthlyProductId: 'prod_monthly', yearlyProductId: 'prod_yearly', + dodoApiKey: 'api-key', + dodoWebhookKey: 'webhook-key', } const configsServiceMock = { diff --git a/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts b/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts index add10c95212..54ed6356019 100644 --- a/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts +++ b/apps/core/test/src/modules/reader/reader.repository.pg.e2e.spec.ts @@ -75,7 +75,7 @@ describe('ReaderRepository membership summary + filter (real PG)', () => { provider: 'manual', plan: 'yearly', status: 'cancelled', - currentPeriodEnd: future, + currentPeriodEnd: past, }, { id: snowflake.nextId(), diff --git a/packages/api-client/__tests__/controllers/membership.test.ts b/packages/api-client/__tests__/controllers/membership.test.ts index 167835e004c..f85ef15aded 100644 --- a/packages/api-client/__tests__/controllers/membership.test.ts +++ b/packages/api-client/__tests__/controllers/membership.test.ts @@ -17,6 +17,21 @@ describe('test Membership client', () => { expect(data).toEqual({ checkoutUrl: mocked.checkout_url }) }) + test('POST /membership/checkout with a return path', async () => { + const mocked = mockResponse( + '/membership/checkout', + { checkout_url: 'https://pay.example.com/session/return' }, + 'post', + { plan: 'yearly', returnPath: '/posts/member-only' }, + ) + + const data = await client.membership.checkout( + 'yearly', + '/posts/member-only', + ) + expect(data).toEqual({ checkoutUrl: mocked.checkout_url }) + }) + test('GET /membership/status returns active membership', async () => { const mocked = mockResponse('/membership/status', { status: 'active', diff --git a/packages/api-client/controllers/membership.ts b/packages/api-client/controllers/membership.ts index e230899bb65..0d410d4055a 100644 --- a/packages/api-client/controllers/membership.ts +++ b/packages/api-client/controllers/membership.ts @@ -34,7 +34,7 @@ export class MembershipController implements IController { checkout(plan: MembershipPlan, returnPath?: string) { return this.proxy.checkout.post({ - data: { plan, returnPath }, + data: returnPath === undefined ? { plan } : { plan, returnPath }, }) } From 1691a64ce497ab7bf5d96ad26f13432ca102051c Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 19 Jul 2026 17:14:29 +0800 Subject: [PATCH 37/37] test: provide comment entitlement dependency --- .../contracts/admin/comments-admin.contract.spec.ts | 11 +++++++++++ apps/core/test/src/contracts/comment.contract.spec.ts | 11 +++++++++++ .../contracts/yohaku/comment-thread.contract.spec.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts index fcd25e66c45..ce8c55b0845 100644 --- a/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts @@ -17,6 +17,7 @@ import { CommentController } from '~/modules/comment/comment.controller' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' import { CommentService } from '~/modules/comment/comment.service' import { ConfigsService } from '~/modules/configs/configs.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { ReaderService } from '~/modules/reader/reader.service' import { @@ -169,6 +170,15 @@ const readerServiceProvider = { }, } +const entitlementServiceProvider = { + provide: EntitlementService, + useValue: { + async getActiveMemberIds() { + return new Set() + }, + }, +} + // `pin: boolean` is the new PG-shape replacement for the legacy `pin: Date` // field. The legacy-key guard must allow it. const ALLOWED_LEGACY_KEYS = ['pin'] @@ -210,6 +220,7 @@ describe('CommentController admin contract (e2e)', () => { lifecycleProvider, configsProvider, readerServiceProvider, + entitlementServiceProvider, ...eventEmitterProvider, ], }) diff --git a/apps/core/test/src/contracts/comment.contract.spec.ts b/apps/core/test/src/contracts/comment.contract.spec.ts index 11697f9ea58..d177c62b430 100644 --- a/apps/core/test/src/contracts/comment.contract.spec.ts +++ b/apps/core/test/src/contracts/comment.contract.spec.ts @@ -5,6 +5,7 @@ import { CommentController } from '~/modules/comment/comment.controller' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' import { CommentService } from '~/modules/comment/comment.service' import { ConfigsService } from '~/modules/configs/configs.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { ReaderService } from '~/modules/reader/reader.service' import { @@ -197,6 +198,15 @@ const readerServiceProvider = { }, } +const entitlementServiceProvider = { + provide: EntitlementService, + useValue: { + async getActiveMemberIds() { + return new Set() + }, + }, +} + const getResponseData = (body: any) => Array.isArray(body.data) ? body.data : body.data?.data @@ -208,6 +218,7 @@ describe('CommentController contract (e2e)', () => { lifecycleProvider, configsProvider, readerServiceProvider, + entitlementServiceProvider, ...eventEmitterProvider, ], }) diff --git a/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts b/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts index 0aad0da772a..8b3736a32af 100644 --- a/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts @@ -18,6 +18,7 @@ import { CommentController } from '~/modules/comment/comment.controller' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' import { CommentService } from '~/modules/comment/comment.service' import { ConfigsService } from '~/modules/configs/configs.service' +import { EntitlementService } from '~/modules/membership/entitlement.service' import { ReaderService } from '~/modules/reader/reader.service' import { @@ -113,6 +114,15 @@ const readerServiceProvider = { }, } +const entitlementServiceProvider = { + provide: EntitlementService, + useValue: { + async getActiveMemberIds() { + return new Set() + }, + }, +} + const getResponseData = (body: any) => Array.isArray(body.data) ? body.data : body.data?.data @@ -124,6 +134,7 @@ describe('Yohaku contract — comment thread (e2e)', () => { lifecycleProvider, configsProvider, readerServiceProvider, + entitlementServiceProvider, ...eventEmitterProvider, ], })