Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Email unsubscribe #1967

Merged
merged 2 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions clients/apps/web/src/app/(public)/unsubscribe/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Sorry, we could not unsubscribe you.</div>
}

return (
<div className="flex h-full w-full justify-center pt-8">
<div>
<ShadowBox>
<div className="flex flex-col gap-4">
<h2 className="font-semibold">
Unsubscribe from {searchParams.org}
</h2>

{showMessage ? (
<Banner color="blue">You&apos;ve been unsubscribed.</Banner>
) : null}

<div className="flex gap-4">
<Button onClick={onUnsubscribe} loading={loading}>
Yes, unsubscribe me
</Button>
<Button
onClick={() => {
router.push(`/${searchParams.org}`)
}}
variant={'secondary'}
>
No, I changed my mind
</Button>
</div>
</div>
</ShadowBox>
</div>
</div>
)
}
52 changes: 52 additions & 0 deletions clients/packages/sdk/src/client/apis/ArticlesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface ArticlesApiCreateRequest {
articleCreate: ArticleCreate;
}

export interface ArticlesApiEmailUnsubscribeRequest {
articleSubscriptionId: string;
}

export interface ArticlesApiGetRequest {
id: string;
}
Expand Down Expand Up @@ -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<runtime.ApiResponse<any>> {
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<any>(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<any> {
const response = await this.emailUnsubscribeRaw(requestParameters, initOverrides);
return await response.value();
}

/**
* Get article.
* Get article (Public API)
Expand Down
33 changes: 33 additions & 0 deletions server/migrations/versions/2023-12-13_emails_unsubscribed_at.py
Original file line number Diff line number Diff line change
@@ -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 ###
18 changes: 18 additions & 0 deletions server/polar/article/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ArticlePreviewResponse,
ArticleReceiversResponse,
ArticleSentResponse,
ArticleUnsubscribeResponse,
ArticleUpdate,
ArticleViewedResponse,
)
Expand All @@ -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],
Expand Down
4 changes: 4 additions & 0 deletions server/polar/article/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,7 @@ class ArticleSentResponse(Schema):

class ArticleDeleteResponse(Schema):
ok: bool


class ArticleUnsubscribeResponse(Schema):
ok: bool
42 changes: 30 additions & 12 deletions server/polar/article/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
6 changes: 6 additions & 0 deletions server/polar/models/articles_subscription.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime
from uuid import UUID

from sqlalchemy import (
TIMESTAMP,
Boolean,
ForeignKey,
UniqueConstraint,
Expand All @@ -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")
Expand Down
43 changes: 42 additions & 1 deletion server/tests/article/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Loading