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)