diff --git a/clients/apps/web/src/app/(public)/unsubscribe/page.tsx b/clients/apps/web/src/app/(public)/unsubscribe/page.tsx
new file mode 100644
index 0000000000..612ac2b2a4
--- /dev/null
+++ b/clients/apps/web/src/app/(public)/unsubscribe/page.tsx
@@ -0,0 +1,73 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { api } from 'polarkit/api'
+import { Button, ShadowBox } from 'polarkit/components/ui/atoms'
+import { Banner } from 'polarkit/components/ui/molecules'
+import { useState } from 'react'
+
+export default function Page({
+ searchParams,
+}: {
+ searchParams: { id?: string; org?: string }
+}) {
+ const router = useRouter()
+
+ const [loading, setLoading] = useState(false)
+ const [showMessage, setShowMessage] = useState(false)
+
+ const onUnsubscribe = async () => {
+ if (!searchParams.id) {
+ return
+ }
+
+ setLoading(true)
+
+ api.articles
+ .emailUnsubscribe({
+ articleSubscriptionId: searchParams.id,
+ })
+ .then(() => {
+ setShowMessage(true)
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }
+
+ if (!searchParams.id || !searchParams.org) {
+ return
Sorry, we could not unsubscribe you.
+ }
+
+ return (
+
+
+
+
+
+ Unsubscribe from {searchParams.org}
+
+
+ {showMessage ? (
+
You've been unsubscribed.
+ ) : null}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/clients/packages/sdk/src/client/apis/ArticlesApi.ts b/clients/packages/sdk/src/client/apis/ArticlesApi.ts
index e419795397..576fdb13bd 100644
--- a/clients/packages/sdk/src/client/apis/ArticlesApi.ts
+++ b/clients/packages/sdk/src/client/apis/ArticlesApi.ts
@@ -37,6 +37,10 @@ export interface ArticlesApiCreateRequest {
articleCreate: ArticleCreate;
}
+export interface ArticlesApiEmailUnsubscribeRequest {
+ articleSubscriptionId: string;
+}
+
export interface ArticlesApiGetRequest {
id: string;
}
@@ -172,6 +176,54 @@ export class ArticlesApi extends runtime.BaseAPI {
return await response.value();
}
+ /**
+ * Unsubscribe user from articles in emails.
+ * Stop delivery of articles via email.
+ */
+ async emailUnsubscribeRaw(requestParameters: ArticlesApiEmailUnsubscribeRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> {
+ if (requestParameters.articleSubscriptionId === null || requestParameters.articleSubscriptionId === undefined) {
+ throw new runtime.RequiredError('articleSubscriptionId','Required parameter requestParameters.articleSubscriptionId was null or undefined when calling emailUnsubscribe.');
+ }
+
+ const queryParameters: any = {};
+
+ if (requestParameters.articleSubscriptionId !== undefined) {
+ queryParameters['article_subscription_id'] = requestParameters.articleSubscriptionId;
+ }
+
+ const headerParameters: runtime.HTTPHeaders = {};
+
+ if (this.configuration && this.configuration.accessToken) {
+ const token = this.configuration.accessToken;
+ const tokenString = await token("HTTPBearer", []);
+
+ if (tokenString) {
+ headerParameters["Authorization"] = `Bearer ${tokenString}`;
+ }
+ }
+ const response = await this.request({
+ path: `/api/v1/articles/unsubscribe`,
+ method: 'GET',
+ headers: headerParameters,
+ query: queryParameters,
+ }, initOverrides);
+
+ if (this.isJsonMime(response.headers.get('content-type'))) {
+ return new runtime.JSONApiResponse(response);
+ } else {
+ return new runtime.TextApiResponse(response) as any;
+ }
+ }
+
+ /**
+ * Unsubscribe user from articles in emails.
+ * Stop delivery of articles via email.
+ */
+ async emailUnsubscribe(requestParameters: ArticlesApiEmailUnsubscribeRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise {
+ const response = await this.emailUnsubscribeRaw(requestParameters, initOverrides);
+ return await response.value();
+ }
+
/**
* Get article.
* Get article (Public API)
diff --git a/server/migrations/versions/2023-12-13_emails_unsubscribed_at.py b/server/migrations/versions/2023-12-13_emails_unsubscribed_at.py
new file mode 100644
index 0000000000..080b6119f8
--- /dev/null
+++ b/server/migrations/versions/2023-12-13_emails_unsubscribed_at.py
@@ -0,0 +1,33 @@
+"""emails_unsubscribed_at
+
+Revision ID: 10807a5d65b9
+Revises: 2f8b6cf73c0e
+Create Date: 2023-12-13 15:00:15.287097
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# Polar Custom Imports
+from polar.kit.extensions.sqlalchemy import PostgresUUID
+
+# revision identifiers, used by Alembic.
+revision = "10807a5d65b9"
+down_revision = "2f8b6cf73c0e"
+branch_labels: tuple[str] | None = None
+depends_on: tuple[str] | None = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column(
+ "article_subscriptions",
+ sa.Column("emails_unsubscribed_at", sa.TIMESTAMP(timezone=True), nullable=True),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column("article_subscriptions", "emails_unsubscribed_at")
+ # ### end Alembic commands ###
diff --git a/server/polar/article/endpoints.py b/server/polar/article/endpoints.py
index d3f21b148c..8e36050d68 100644
--- a/server/polar/article/endpoints.py
+++ b/server/polar/article/endpoints.py
@@ -25,6 +25,7 @@
ArticlePreviewResponse,
ArticleReceiversResponse,
ArticleSentResponse,
+ ArticleUnsubscribeResponse,
ArticleUpdate,
ArticleViewedResponse,
)
@@ -33,6 +34,23 @@
router = APIRouter(tags=["articles"])
+@router.get(
+ "/articles/unsubscribe",
+ tags=[Tags.INTERNAL],
+ response_model=ArticleUnsubscribeResponse,
+ description="Unsubscribe user from articles in emails.",
+ summary="Stop delivery of articles via email.",
+ status_code=200,
+ responses={404: {}},
+)
+async def email_unsubscribe(
+ article_subscription_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+) -> ArticleUnsubscribeResponse:
+ await article_service.unsubscribe(session, article_subscription_id)
+ return ArticleUnsubscribeResponse(ok=True)
+
+
@router.get(
"/articles",
response_model=ListResource[ArticleSchema],
diff --git a/server/polar/article/schemas.py b/server/polar/article/schemas.py
index ef2785a90f..0853190faa 100644
--- a/server/polar/article/schemas.py
+++ b/server/polar/article/schemas.py
@@ -179,3 +179,7 @@ class ArticleSentResponse(Schema):
class ArticleDeleteResponse(Schema):
ok: bool
+
+
+class ArticleUnsubscribeResponse(Schema):
+ ok: bool
diff --git a/server/polar/article/service.py b/server/polar/article/service.py
index 1e981932f6..861d4bdda9 100644
--- a/server/polar/article/service.py
+++ b/server/polar/article/service.py
@@ -307,18 +307,21 @@ async def list_receivers(
)
.join(
UserOrganization,
- onclause=UserOrganization.user_id == User.id,
+ onclause=(UserOrganization.user_id == User.id)
+ & (UserOrganization.organization_id == organization_id),
isouter=True,
)
.join(
ArticlesSubscription,
- onclause=ArticlesSubscription.user_id == User.id,
+ onclause=(ArticlesSubscription.user_id == User.id)
+ & (ArticlesSubscription.organization_id == organization_id)
+ & (ArticlesSubscription.emails_unsubscribed_at.is_(None)),
isouter=True,
)
).where(
or_(
- UserOrganization.organization_id == organization_id,
user_subscription_clause,
+ UserOrganization.organization_id == organization_id,
)
)
@@ -423,15 +426,14 @@ def _get_readable_articles_statement(
.join(Article.organization)
.join(
ArticlesSubscription,
- onclause=and_(
- and_(
- ArticlesSubscription.user_id == auth_subject.id
- if isinstance(auth_subject, User)
- else false(),
- ArticlesSubscription.organization_id == Organization.id,
- ),
- ArticlesSubscription.deleted_at.is_(None),
- ),
+ onclause=(
+ ArticlesSubscription.user_id == auth_subject.id
+ if isinstance(auth_subject, User)
+ else false()
+ )
+ & (ArticlesSubscription.organization_id == Organization.id)
+ & ArticlesSubscription.deleted_at.is_(None)
+ & ArticlesSubscription.emails_unsubscribed_at.is_(None),
isouter=True,
)
.join(
@@ -471,5 +473,21 @@ def _get_subscribed_articles_statement(
return statement
+ async def unsubscribe(
+ self,
+ session: AsyncSession,
+ id: UUID,
+ ) -> None:
+ stmt = (
+ sql.update(ArticlesSubscription)
+ .values({"emails_unsubscribed_at": utc_now()})
+ .where(
+ ArticlesSubscription.id == id,
+ ArticlesSubscription.emails_unsubscribed_at.is_(None),
+ )
+ )
+ await session.execute(stmt)
+ await session.commit()
+
article_service = ArticleService()
diff --git a/server/polar/models/articles_subscription.py b/server/polar/models/articles_subscription.py
index 9e946e4e11..1cad48ebc0 100644
--- a/server/polar/models/articles_subscription.py
+++ b/server/polar/models/articles_subscription.py
@@ -1,6 +1,8 @@
+from datetime import datetime
from uuid import UUID
from sqlalchemy import (
+ TIMESTAMP,
Boolean,
ForeignKey,
UniqueConstraint,
@@ -26,6 +28,10 @@ class ArticlesSubscription(RecordModel):
PostgresUUID, ForeignKey("organizations.id"), nullable=False, index=True
)
+ emails_unsubscribed_at: Mapped[datetime | None] = mapped_column(
+ TIMESTAMP(timezone=True), nullable=True, default=None
+ )
+
@declared_attr
def organization(cls) -> Mapped[Organization]:
return relationship(Organization, lazy="raise")
diff --git a/server/tests/article/test_service.py b/server/tests/article/test_service.py
index fa4a308e91..240c8608c5 100644
--- a/server/tests/article/test_service.py
+++ b/server/tests/article/test_service.py
@@ -19,7 +19,7 @@
UserOrganization,
)
from polar.postgres import AsyncSession
-from tests.fixtures.random_objects import create_user
+from tests.fixtures.random_objects import create_organization, create_user
def random_string(length: int = 32) -> str:
@@ -634,3 +634,44 @@ async def test_paid_subscription(
receivers = await article_service.list_receivers(session, organization.id, True)
assert len(receivers) == 1
assert receivers[0] == (user_second.id, True, False)
+
+ async def test_paid_subscription_and_member(
+ self,
+ session: AsyncSession,
+ user: User,
+ organization: Organization,
+ user_organization: UserOrganization,
+ ) -> None:
+ await create_articles_subscription(
+ session, user=user, organization=organization, paid_subscriber=True
+ )
+
+ receivers = await article_service.list_receivers(session, organization.id, True)
+ assert len(receivers) == 1
+ assert receivers[0] == (user.id, True, True)
+
+ async def test_paid_subscription_and_member_member_other_orgs(
+ self,
+ session: AsyncSession,
+ user: User,
+ organization: Organization,
+ user_organization: UserOrganization,
+ ) -> None:
+ # make user member of other orgs
+ # this test checks that list_receivers doesn't have a bad join
+ other_org = await create_organization(session)
+ a = await UserOrganization(
+ user_id=user.id,
+ organization_id=other_org.id,
+ is_admin=True,
+ ).save(
+ session=session,
+ )
+
+ await create_articles_subscription(
+ session, user=user, organization=organization, paid_subscriber=True
+ )
+
+ receivers = await article_service.list_receivers(session, organization.id, True)
+ assert len(receivers) == 1
+ assert receivers[0] == (user.id, True, True)