Skip to content

Commit c2c385e

Browse files
kevinortiz43claude
andcommitted
Block incomplete profiles from commenting on events
Gate the shared Threads.PostReply RPC on event threads with has_completed_profile (mirroring the CreateDiscussion/CreateEvent precondition pattern), and surface ProfileIncompleteDialog on the event comment box via an opt-in attemptedAction prop threaded EventPage -> CommentTree -> CommentForm. Scoped to events only: discussions, groups, and pages share the same comment components/RPC and are unaffected. Also remove a negative top margin on the community/discussion PageHeader that let the full-bleed map cover the push-notification banner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 649f93c commit c2c385e

10 files changed

Lines changed: 251 additions & 18 deletions

File tree

app/backend/src/couchers/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"host_request_too_short2_one": "Host request cannot be shorter than {{count}} character.",
9696
"host_request_too_short2_other": "Host request cannot be shorter than {{count}} characters.",
9797
"hosting_status_required": "Hosting status is required.",
98+
"incomplete_profile_comment_on_event": "You have to complete your profile before you can comment on an event.",
9899
"incomplete_profile_create_discussion": "You have to complete your profile before you can start a discussion.",
99100
"incomplete_profile_create_event": "You have to complete your profile before you can create an event.",
100101
"incomplete_profile_create_public_trip": "You have to complete your profile before you can create a public trip.",

app/backend/src/couchers/servicers/threads.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from couchers.context import CouchersContext, make_background_user_context, make_notification_user_context
1111
from couchers.db import session_scope
12+
from couchers.helpers.completed_profile import has_completed_profile
1213
from couchers.jobs.enqueue import queue_job
1314
from couchers.models import (
1415
Comment,
@@ -48,6 +49,18 @@ def unpack_thread_id(thread_id: int) -> tuple[int, int]:
4849
return divmod(thread_id, 10)
4950

5051

52+
def _is_event_thread(session: Session, database_id: int, depth: int) -> bool:
53+
"""Returns whether the given (database_id, depth) thread location belongs to an Event's thread."""
54+
thread_db_id = (
55+
database_id
56+
if depth == 0
57+
else session.execute(select(Comment.thread_id).where(Comment.id == database_id)).scalar_one_or_none()
58+
)
59+
if thread_db_id is None:
60+
return False
61+
return bool(session.execute(select(exists().where(Event.thread_id == thread_db_id))).scalar())
62+
63+
5164
def total_num_responses(session: Session, context: CouchersContext, database_id: int) -> int:
5265
"""Return the total number of visible, non-deleted comments and replies to the thread with
5366
database id database_id.
@@ -385,6 +398,13 @@ def PostReply(
385398
if depth not in (0, 1):
386399
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "thread_not_found")
387400

401+
if _is_event_thread(session, database_id, depth):
402+
user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
403+
if not has_completed_profile(session, user):
404+
context.abort_with_error_code(
405+
grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_comment_on_event"
406+
)
407+
388408
object_to_add: Comment | Reply | None = None
389409

390410
def create_object(moderation_state_id: int) -> int:

app/backend/src/tests/test_threads.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import string
22
import textwrap
3+
from datetime import timedelta
34

45
import grpc
56
import pytest
@@ -17,11 +18,12 @@
1718
User,
1819
)
1920
from couchers.models.discussions import CommentVersion, ContentChangeType, ReplyVersion
20-
from couchers.proto import moderation_pb2, threads_pb2
21+
from couchers.proto import discussions_pb2, events_pb2, moderation_pb2, threads_pb2
2122
from couchers.servicers.threads import pack_thread_id
22-
from couchers.utils import now
23+
from couchers.utils import Timestamp_from_datetime, now
2324
from tests.fixtures.db import generate_user
24-
from tests.fixtures.sessions import real_moderation_session, threads_session
25+
from tests.fixtures.sessions import discussions_session, events_session, real_moderation_session, threads_session
26+
from tests.test_communities import create_community
2527

2628

2729
@pytest.fixture(autouse=True)
@@ -539,6 +541,103 @@ def test_delete_reply_creates_version_record(db):
539541
assert v.editor_user_id == user.id
540542

541543

544+
def _create_event_and_get_thread_id(organizer, organizer_token: str) -> int:
545+
"""Helper: create an Event via the API, return its (packed) thread_id."""
546+
with session_scope() as session:
547+
create_community(session, 0, 2, "Testing Community", [organizer], [], None)
548+
549+
start_time = now() + timedelta(hours=2)
550+
end_time = start_time + timedelta(hours=3)
551+
with events_session(organizer_token) as api:
552+
res = api.CreateEvent(
553+
events_pb2.CreateEventReq(
554+
title="Dummy event",
555+
content="Dummy content.",
556+
location=events_pb2.EventLocation(
557+
address="Near Null Island",
558+
lat=0.1,
559+
lng=0.2,
560+
),
561+
start_time=Timestamp_from_datetime(start_time),
562+
end_time=Timestamp_from_datetime(end_time),
563+
timezone="UTC",
564+
)
565+
)
566+
return int(res.thread.thread_id)
567+
568+
569+
def test_post_reply_incomplete_profile_blocked_on_event_comment(db):
570+
"""An incomplete-profile user cannot post a top-level comment on an event's thread."""
571+
organizer, organizer_token = generate_user()
572+
_commenter, commenter_token = generate_user(complete_profile=False)
573+
574+
event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
575+
576+
with threads_session(commenter_token) as api:
577+
with pytest.raises(grpc.RpcError) as e:
578+
api.PostReply(threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello"))
579+
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
580+
581+
582+
def test_post_reply_incomplete_profile_blocked_on_event_reply(db):
583+
"""An incomplete-profile user cannot post a nested reply within an event's thread."""
584+
organizer, organizer_token = generate_user()
585+
_commenter, commenter_token = generate_user()
586+
_replier, replier_token = generate_user(complete_profile=False)
587+
588+
event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
589+
590+
with threads_session(commenter_token) as api:
591+
comment_thread_id = api.PostReply(
592+
threads_pb2.PostReplyReq(thread_id=event_thread_id, content="top-level comment")
593+
).thread_id
594+
595+
with threads_session(replier_token) as api:
596+
with pytest.raises(grpc.RpcError) as e:
597+
api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="a reply"))
598+
assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
599+
600+
601+
def test_post_reply_complete_profile_allowed_on_event_comment(db):
602+
"""A complete-profile user can post a comment on an event's thread."""
603+
organizer, organizer_token = generate_user()
604+
_commenter, commenter_token = generate_user()
605+
606+
event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
607+
608+
with threads_session(commenter_token) as api:
609+
comment_thread_id = api.PostReply(
610+
threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello")
611+
).thread_id
612+
assert comment_thread_id
613+
614+
615+
def test_post_reply_incomplete_profile_allowed_on_non_event_thread(db):
616+
"""An incomplete-profile user can still comment on a non-event thread (e.g. a discussion's thread),
617+
proving the gate is scoped to events only."""
618+
admin, admin_token = generate_user()
619+
_commenter, commenter_token = generate_user(complete_profile=False)
620+
621+
with session_scope() as session:
622+
community_id = create_community(session, 0, 1, "Testing Community", [admin], [], None).id
623+
624+
with discussions_session(admin_token) as api:
625+
discussion = api.CreateDiscussion(
626+
discussions_pb2.CreateDiscussionReq(
627+
title="A discussion",
628+
content="Content",
629+
owner_community_id=community_id,
630+
)
631+
)
632+
discussion_thread_id = discussion.thread.thread_id
633+
634+
with threads_session(commenter_token) as api:
635+
comment_thread_id = api.PostReply(
636+
threads_pb2.PostReplyReq(thread_id=discussion_thread_id, content="hello")
637+
).thread_id
638+
assert comment_thread_id
639+
640+
542641
def test_edit_comment_multiple_edits_creates_multiple_version_records(db):
543642
user, token = generate_user()
544643
_, comment_thread_id = _make_thread_and_comment(token, content="v1")

app/web/components/ProfileIncompleteDialog/ProfileIncompleteDialog.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ import Link from "next/link";
1313
import React from "react";
1414
import { howToCompleteProfileUrl, routeToEditProfile } from "routes";
1515

16+
export type ProfileIncompleteAction =
17+
| "create_event"
18+
| "send_message"
19+
| "send_request"
20+
| "create_public_trip"
21+
| "send_friend_request"
22+
| "create_discussion"
23+
| "comment_on_event";
24+
1625
interface ProfileIncompleteDialogProps {
1726
open: boolean;
1827
onClose: () => void;
19-
attempted_action:
20-
| "create_event"
21-
| "send_message"
22-
| "send_request"
23-
| "create_public_trip"
24-
| "send_friend_request"
25-
| "create_discussion";
28+
attempted_action: ProfileIncompleteAction;
2629
}
2730

2831
export default function ProfileIncompleteDialog({

app/web/features/communities/PageHeader.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const StyledWrapper = styled("div")(({ theme }) => ({
2121
},
2222
[theme.breakpoints.up("md")]: {
2323
height: "16rem",
24-
marginTop: theme.spacing(-2),
2524
},
2625
}));
2726

app/web/features/communities/discussions/CommentForm.test.tsx

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import mockRouter from "next-router-mock";
4+
import type { ComponentProps } from "react";
45
import { discussionBaseRoute } from "routes";
56
import { service } from "service";
67
import wrapper from "test/hookWrapper";
78
import i18n from "test/i18n";
9+
import { getAccountInfo } from "test/serviceMockDefaults";
810
import { MockedService } from "test/utils";
911

1012
import CommentForm from "./CommentForm";
@@ -16,14 +18,26 @@ jest.mock("components/MarkdownInput");
1618
const postReplyMock = service.threads.postReply as MockedService<
1719
typeof service.threads.postReply
1820
>;
21+
const getAccountInfoMock = service.account.getAccountInfo as MockedService<
22+
typeof service.account.getAccountInfo
23+
>;
1924

20-
function renderCommentForm() {
25+
function renderCommentForm(
26+
props: Partial<ComponentProps<typeof CommentForm>> = {},
27+
) {
2128
mockRouter.setCurrentUrl(
2229
`${discussionBaseRoute}/1/what-is-there-to-do-in-amsterdam`,
2330
);
24-
render(<CommentForm threadId={999} shown={true} />, { wrapper });
31+
render(<CommentForm threadId={999} shown={true} {...props} />, { wrapper });
2532
}
2633

34+
beforeEach(() => {
35+
// CommentForm always reads accountInfo (to gate submission when an
36+
// attemptedAction is set); give it a default so tests that don't care
37+
// about the gate don't hit an unmocked query.
38+
getAccountInfoMock.mockImplementation(getAccountInfo);
39+
});
40+
2741
describe("Comment form", () => {
2842
beforeAll(() => {
2943
jest.useFakeTimers();
@@ -96,3 +110,59 @@ describe("Comment form", () => {
96110
expect(postReplyMock).not.toHaveBeenCalled();
97111
});
98112
});
113+
114+
describe("Comment form with attemptedAction (event comment gate)", () => {
115+
beforeAll(() => {
116+
// The "Comment form" describe above leaves fake timers active; restore
117+
// real timers so `await user.type`/`user.click` resolve normally here.
118+
jest.useRealTimers();
119+
});
120+
121+
beforeEach(() => {
122+
postReplyMock.mockClear();
123+
});
124+
125+
it("shows the profile incomplete dialog instead of posting when the profile is incomplete", async () => {
126+
getAccountInfoMock.mockImplementation(async () => ({
127+
...(await getAccountInfo()),
128+
profileComplete: false,
129+
}));
130+
renderCommentForm({ attemptedAction: "comment_on_event" });
131+
132+
const user = userEvent.setup();
133+
134+
const commentInput = (await screen.findByLabelText(
135+
t("communities:write_comment_a11y_label"),
136+
)) as HTMLInputElement;
137+
await user.type(commentInput, "This is a valid comment");
138+
139+
await user.click(
140+
screen.getByRole("button", { name: t("communities:comment") }),
141+
);
142+
143+
expect(
144+
await screen.findByLabelText(t("profile:complete_profile_dialog.title")),
145+
).toBeVisible();
146+
expect(postReplyMock).not.toHaveBeenCalled();
147+
});
148+
149+
it("posts the comment as usual when the profile is complete", async () => {
150+
renderCommentForm({ attemptedAction: "comment_on_event" });
151+
152+
const user = userEvent.setup();
153+
154+
const commentInput = (await screen.findByLabelText(
155+
t("communities:write_comment_a11y_label"),
156+
)) as HTMLInputElement;
157+
await user.type(commentInput, "This is a valid comment");
158+
159+
await user.click(
160+
screen.getByRole("button", { name: t("communities:comment") }),
161+
);
162+
163+
await waitFor(() => expect(postReplyMock).toHaveBeenCalledTimes(1));
164+
expect(
165+
screen.queryByLabelText(t("profile:complete_profile_dialog.title")),
166+
).not.toBeInTheDocument();
167+
});
168+
});

app/web/features/communities/discussions/CommentForm.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
44
import Alert from "components/Alert";
55
import Button from "components/Button";
66
import MarkdownInput, { MarkdownInputProps } from "components/MarkdownInput";
7+
import ProfileIncompleteDialog, {
8+
ProfileIncompleteAction,
9+
} from "components/ProfileIncompleteDialog/ProfileIncompleteDialog";
10+
import useAccountInfo from "features/auth/useAccountInfo";
711
import { RpcError } from "grpc-web";
812
import { useTranslation } from "i18n";
913
import { COMMUNITIES, GLOBAL } from "i18n/namespaces";
10-
import React, { useRef } from "react";
14+
import React, { useRef, useState } from "react";
1115
import { useForm } from "react-hook-form";
1216
import { service } from "service";
1317
import { theme } from "theme";
@@ -33,6 +37,7 @@ const StyledButtonsContainer = styled("div")(() => ({
3337
}));
3438

3539
interface CommentFormProps {
40+
attemptedAction?: ProfileIncompleteAction;
3641
hideable?: boolean;
3742
onClose?(): void;
3843
shown?: boolean;
@@ -44,10 +49,23 @@ interface CommentData {
4449
}
4550

4651
function InternalCommentForm(
47-
{ hideable = false, onClose, shown = false, threadId }: CommentFormProps,
52+
{
53+
attemptedAction,
54+
hideable = false,
55+
onClose,
56+
shown = false,
57+
threadId,
58+
}: CommentFormProps,
4859
ref: React.ForwardedRef<HTMLFormElement>,
4960
) {
5061
const { t } = useTranslation([GLOBAL, COMMUNITIES]);
62+
const { data: accountInfo } = useAccountInfo();
63+
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
64+
// Opt-in gate: only enforced when a caller passes attemptedAction (currently
65+
// only the event comment box). Discussions/groups/pages leave it undefined
66+
// and are unaffected.
67+
const profileIncomplete =
68+
attemptedAction !== undefined && accountInfo?.profileComplete === false;
5169
const {
5270
control,
5371
handleSubmit,
@@ -75,6 +93,11 @@ function InternalCommentForm(
7593
});
7694

7795
const onSubmit = handleSubmit((data) => {
96+
if (profileIncomplete) {
97+
setProfileDialogOpen(true);
98+
return;
99+
}
100+
78101
const trimmedValue = data.content.trim();
79102
const newData = {
80103
content: trimmedValue,
@@ -85,6 +108,13 @@ function InternalCommentForm(
85108

86109
return (
87110
<Collapse data-testid={`comment-${threadId}-comment-form`} in={shown}>
111+
{attemptedAction && (
112+
<ProfileIncompleteDialog
113+
open={profileDialogOpen}
114+
onClose={() => setProfileDialogOpen(false)}
115+
attempted_action={attemptedAction}
116+
/>
117+
)}
88118
<StyledForm onSubmit={onSubmit} ref={ref}>
89119
{error && <Alert severity="error">{error.message}</Alert>}
90120
<span style={visuallyHidden} id={`comment-${threadId}-reply-label`}>

0 commit comments

Comments
 (0)